diff --git a/cmd/spire-agent/cli/run/run_posix_test.go b/cmd/spire-agent/cli/run/run_posix_test.go index 7442f9b133..aba0169cb8 100644 --- a/cmd/spire-agent/cli/run/run_posix_test.go +++ b/cmd/spire-agent/cli/run/run_posix_test.go @@ -187,28 +187,28 @@ func TestParseConfigGood(t *testing.T) { // Check for plugins configurations expectedPluginConfigs := catalog.PluginConfigs{ { - Type: "plugin_type_agent", - Name: "plugin_name_agent", - Path: "./pluginAgentCmd", - Checksum: "pluginAgentChecksum", - Data: data, - Disabled: false, + Type: "plugin_type_agent", + Name: "plugin_name_agent", + Path: "./pluginAgentCmd", + Checksum: "pluginAgentChecksum", + DataSource: catalog.FixedData(data), + Disabled: false, }, { - Type: "plugin_type_agent", - Name: "plugin_disabled", - Path: "./pluginAgentCmd", - Checksum: "pluginAgentChecksum", - Data: data, - Disabled: true, + Type: "plugin_type_agent", + Name: "plugin_disabled", + Path: "./pluginAgentCmd", + Checksum: "pluginAgentChecksum", + DataSource: catalog.FixedData(data), + Disabled: true, }, { - Type: "plugin_type_agent", - Name: "plugin_enabled", - Path: "./pluginAgentCmd", - Checksum: "pluginAgentChecksum", - Data: data, - Disabled: false, + Type: "plugin_type_agent", + Name: "plugin_enabled", + Path: "./pluginAgentCmd", + Checksum: "pluginAgentChecksum", + DataSource: catalog.FileData("plugin.conf"), + Disabled: false, }, } diff --git a/cmd/spire-agent/cli/run/run_windows_test.go b/cmd/spire-agent/cli/run/run_windows_test.go index 2014ae5587..1843ef880b 100644 --- a/cmd/spire-agent/cli/run/run_windows_test.go +++ b/cmd/spire-agent/cli/run/run_windows_test.go @@ -173,28 +173,28 @@ func TestParseConfigGood(t *testing.T) { // Check for plugins configurations expectedPluginConfigs := catalog.PluginConfigs{ { - Type: "plugin_type_agent", - Name: "plugin_name_agent", - Path: "./pluginAgentCmd", - Checksum: "pluginAgentChecksum", - Data: data, - Disabled: false, + Type: "plugin_type_agent", + Name: "plugin_name_agent", + Path: "./pluginAgentCmd", + Checksum: "pluginAgentChecksum", + DataSource: catalog.FixedData(data), + Disabled: false, }, { - Type: "plugin_type_agent", - Name: "plugin_disabled", - Path: ".\\pluginAgentCmd", - Checksum: "pluginAgentChecksum", - Data: data, - Disabled: true, + Type: "plugin_type_agent", + Name: "plugin_disabled", + Path: ".\\pluginAgentCmd", + Checksum: "pluginAgentChecksum", + DataSource: catalog.FixedData(data), + Disabled: true, }, { - Type: "plugin_type_agent", - Name: "plugin_enabled", - Path: "c:/temp/pluginAgentCmd", - Checksum: "pluginAgentChecksum", - Data: data, - Disabled: false, + Type: "plugin_type_agent", + Name: "plugin_enabled", + Path: "c:/temp/pluginAgentCmd", + Checksum: "pluginAgentChecksum", + DataSource: catalog.FileData("plugin.conf"), + Disabled: false, }, } diff --git a/cmd/spire-server/cli/run/run_test.go b/cmd/spire-server/cli/run/run_test.go index bde25d4b11..893dc6d92f 100644 --- a/cmd/spire-server/cli/run/run_test.go +++ b/cmd/spire-server/cli/run/run_test.go @@ -72,28 +72,28 @@ func TestParseConfigGood(t *testing.T) { // Check for plugins configurations expectedPluginConfigs := catalog.PluginConfigs{ { - Type: "plugin_type_server", - Name: "plugin_name_server", - Path: "./pluginServerCmd", - Checksum: "pluginServerChecksum", - Data: data, - Disabled: false, - }, - { - Type: "plugin_type_server", - Name: "plugin_disabled", - Path: "./pluginServerCmd", - Checksum: "pluginServerChecksum", - Data: data, - Disabled: true, - }, - { - Type: "plugin_type_server", - Name: "plugin_enabled", - Path: "./pluginServerCmd", - Checksum: "pluginServerChecksum", - Data: data, - Disabled: false, + Type: "plugin_type_server", + Name: "plugin_name_server", + Path: "./pluginServerCmd", + Checksum: "pluginServerChecksum", + DataSource: catalog.FixedData(data), + Disabled: false, + }, + { + Type: "plugin_type_server", + Name: "plugin_disabled", + Path: "./pluginServerCmd", + Checksum: "pluginServerChecksum", + DataSource: catalog.FixedData(data), + Disabled: true, + }, + { + Type: "plugin_type_server", + Name: "plugin_enabled", + Path: "./pluginServerCmd", + Checksum: "pluginServerChecksum", + DataSource: catalog.FileData("plugin.conf"), + Disabled: false, }, } diff --git a/conf/agent/agent_full.conf b/conf/agent/agent_full.conf index ccbb2f947a..fa7b1daa64 100644 --- a/conf/agent/agent_full.conf +++ b/conf/agent/agent_full.conf @@ -127,11 +127,14 @@ agent { # # not needed for built-ins) # plugin_checksum = # -# # plugin_data: Plugin-specific data +# # plugin_data: Plugin-specific data (mutually exclusive with plugin_data_file) # plugin_data { # ...configuration options... # } # +# # plugin_data_file: Path to file with plugin-specific data (mutually exclusive with plugin_data) +# plugin_data_file = +# # # enabled: Enable or disable the plugin (enabled by default) # enabled = [true | false] # } diff --git a/conf/server/server_full.conf b/conf/server/server_full.conf index 4d21baafce..14c647ffa6 100644 --- a/conf/server/server_full.conf +++ b/conf/server/server_full.conf @@ -210,11 +210,14 @@ server { # # not needed for built-ins) # plugin_checksum = # -# # plugin_data: Plugin-specific data +# # plugin_data: Plugin-specific data (mutually exclusive with plugin_data_file) # plugin_data { # ...configuration options... # } # +# # plugin_data_file: Path to file with plugin-specific data (mutually exclusive with plugin_data) +# plugin_data_file = +# # # enabled: Enable or disable the plugin (enabled by default) # enabled = [true | false] # } diff --git a/doc/spire_agent.md b/doc/spire_agent.md index 662858f63a..a4863b5609 100644 --- a/doc/spire_agent.md +++ b/doc/spire_agent.md @@ -136,14 +136,61 @@ plugins { The following configuration options are available to configure a plugin: -| Configuration | Description | -|-----------------|-------------------------------------------------------------------------------| -| plugin_cmd | Path to the plugin implementation binary (optional, not needed for built-ins) | -| plugin_checksum | An optional sha256 of the plugin binary (optional, not needed for built-ins) | -| enabled | Enable or disable the plugin (enabled by default) | -| plugin_data | Plugin-specific data | - -Please see the [built-in plugins](#built-in-plugins) section for information on plugins that are available out-of-the-box. +| Configuration | Description | +|------------------|----------------------------------------------------------------------------------------| +| plugin_cmd | Path to the plugin implementation binary (optional, not needed for built-ins) | +| plugin_checksum | An optional sha256 of the plugin binary (optional, not needed for built-ins) | +| enabled | Enable or disable the plugin (enabled by default) | +| plugin_data | Plugin-specific data (mutually exclusive with `plugin_data_file`) | +| plugin_data_file | Path to a file containing plugin-specific data (mutually exclusive with `plugin_data`) | + +Please see the [built-in plugins](#built-in-plugins) section below for information on plugins that are available out-of-the-box. + +### Examples + +#### Built-in Plugin with Static Configuration + +```hcl +plugins { + SomeType "some_plugin" { + plugin_data = { + option1 = "foo" + option2 = 3 + } + } +} +``` + +#### External Plugin with Dynamic Configuration + +In the `agent.conf`, declare the plugin using the `plugin_data_file` option to source the plugin configuration from file. + +```hcl +plugins { + SomeType "some_plugin" { + plugin_cmd = "./path/to/plugin" + plugin_checksum = "4e1243bd22c66e76c2ba9eddc1f91394e57f9f83" + plugin_data_file = "some_plugin.conf" + } +} +``` + +And then in `some_plugin.conf` you place the plugin configuration: + +```hcl +option1 = "foo" +option2 = 3 +``` + +### Reconfiguring plugins (Posix only) + +Plugins that use dynamic configuration sources (i.e. `plugin_data_file`) can be reconfigured at runtime by sending a `SIGUSR1` signal to SPIRE Agent. This is true for both built-in and external plugins. + +SPIRE Agent, upon receipt of the signal, does the following: + +1. Reloads the plugin data +2. Compares the plugin data to the previous data +3. If changed, the plugin is reconfigured with the new data ## Telemetry configuration diff --git a/doc/spire_server.md b/doc/spire_server.md index 2b94a0397a..db40dfb2c2 100644 --- a/doc/spire_server.md +++ b/doc/spire_server.md @@ -139,15 +139,64 @@ plugins { The following configuration options are available to configure a plugin: -| Configuration | Description | -|-----------------|-------------------------------------------------------------------------------| -| plugin_cmd | Path to the plugin implementation binary (optional, not needed for built-ins) | -| plugin_checksum | An optional sha256 of the plugin binary (optional, not needed for built-ins) | -| enabled | Enable or disable the plugin (enabled by default) | -| plugin_data | Plugin-specific data | +| Configuration | Description | +|------------------|----------------------------------------------------------------------------------------| +| plugin_cmd | Path to the plugin implementation binary (optional, not needed for built-ins) | +| plugin_checksum | An optional sha256 of the plugin binary (optional, not needed for built-ins) | +| enabled | Enable or disable the plugin (enabled by default) | +| plugin_data | Plugin-specific data (mutually exclusive with `plugin_data_file`) | +| plugin_data_file | Path to a file containing plugin-specific data (mutually exclusive with `plugin_data`) | Please see the [built-in plugins](#built-in-plugins) section below for information on plugins that are available out-of-the-box. +### Examples + +#### Built-in Plugin with Static Configuration + +```hcl +plugins { + SomeType "some_plugin" { + plugin_data = { + option1 = "foo" + option2 = 3 + } + } +} +``` + +#### External Plugin with Dynamic Configuration + +In the `agent.conf`, declare the plugin using the `plugin_data_file` option to source the plugin configuration from file. + +```hcl +plugins { + SomeType "some_plugin" { + plugin_cmd = "./path/to/plugin" + plugin_checksum = "4e1243bd22c66e76c2ba9eddc1f91394e57f9f83" + plugin_data_file = "some_plugin.conf" + } +} +``` + +And then in `some_plugin.conf` you place the plugin configuration: + +```hcl +option1 = "foo" +option2 = 3 +``` + +### Reconfiguring plugins (Posix only) + +Plugins that use dynamic configuration sources (i.e. `plugin_data_file`) can be reconfigured at runtime by sending a `SIGUSR1` signal to SPIRE Server. This is true for both built-in and external plugins. + +SPIRE Server, upon receipt of the signal, does the following: + +1. Reloads the plugin data +2. Compares the plugin data to the previous data +3. If changed, the plugin is reconfigured with the new data + +**Note** The DataStore is not reconfigurable even when configured with a dynamic data source (e.g. `plugin_data_file`). + ## Federation configuration SPIRE Server can be configured to federate with others SPIRE Servers living in different trust domains. SPIRE supports configuring federation relationships in the SPIRE Server configuration file (static relationships) and through the [Trust Domain API](https://github.com/spiffe/spire-api-sdk/blob/main/proto/spire/api/server/trustdomain/v1/trustdomain.proto) (dynamic relationships). This section describes how to configure statically defined relationships in the configuration file. diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 8f7ca73e82..13e54524c9 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -167,6 +167,7 @@ func (a *Agent) Run(ctx context.Context) error { storeService.Run, endpoints.ListenAndServe, metrics.ListenAndServe, + catalog.ReconfigureTask(a.c.Log.WithField(telemetry.SubsystemName, "reconfigurer"), cat), util.SerialRun(a.waitForTestDial, healthChecker.ListenAndServe), } diff --git a/pkg/agent/catalog/catalog.go b/pkg/agent/catalog/catalog.go index 5343917913..0036bff1b9 100644 --- a/pkg/agent/catalog/catalog.go +++ b/pkg/agent/catalog/catalog.go @@ -3,7 +3,6 @@ package catalog import ( "context" "fmt" - "io" "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/spiffeid" @@ -27,6 +26,8 @@ const ( workloadattestorType = "WorkloadAttestor" ) +var ReconfigureTask = catalog.ReconfigureTask + type Catalog interface { GetKeyManager() keymanager.KeyManager GetNodeAttestor() nodeattestor.NodeAttestor @@ -51,8 +52,8 @@ type Repository struct { svidStoreRepository workloadAttestorRepository - log logrus.FieldLogger - catalogCloser io.Closer + log logrus.FieldLogger + catalog *catalog.Catalog } func (repo *Repository) Plugins() map[string]catalog.PluginRepo { @@ -68,9 +69,13 @@ func (repo *Repository) Services() []catalog.ServiceRepo { return nil } +func (repo *Repository) Reconfigure(ctx context.Context) { + repo.catalog.Reconfigure(ctx) +} + func (repo *Repository) Close() { repo.log.Debug("Closing catalog") - if err := repo.catalogCloser.Close(); err == nil { + if err := repo.catalog.Close(); err == nil { repo.log.Info("Catalog closed") } else { repo.log.WithError(err).Error("Failed to close catalog") @@ -86,7 +91,7 @@ func Load(ctx context.Context, config Config) (_ *Repository, err error) { repo := &Repository{ log: config.Log, } - repo.catalogCloser, err = catalog.Load(ctx, catalog.Config{ + repo.catalog, err = catalog.Load(ctx, catalog.Config{ Log: config.Log, CoreConfig: catalog.CoreConfig{ TrustDomain: config.TrustDomain, diff --git a/pkg/common/catalog/catalog.go b/pkg/common/catalog/catalog.go index 7a8bd1ac1e..ddc8d76b6f 100644 --- a/pkg/common/catalog/catalog.go +++ b/pkg/common/catalog/catalog.go @@ -12,8 +12,8 @@ import ( "google.golang.org/grpc" ) -// Catalog is a set of plugin and service repositories. -type Catalog interface { +// Repository is a set of plugin and service repositories. +type Repository interface { // Plugins returns a map of plugin repositories, keyed by the plugin type. Plugins() map[string]PluginRepo @@ -109,6 +109,19 @@ type Config struct { CoreConfig CoreConfig } +type Catalog struct { + closers io.Closer + reconfigurers Reconfigurers +} + +func (c *Catalog) Reconfigure(ctx context.Context) { + c.reconfigurers.Reconfigure(ctx) +} + +func (c *Catalog) Close() error { + return c.closers.Close() +} + // Load loads and configures plugins defined in the configuration. The given // catalog is populated with plugin and service facades for versions // implemented by the loaded plugins. The returned io.Closer can be used to @@ -116,32 +129,33 @@ type Config struct { // given catalog are considered invalidated. If any plugin fails to load or // configure, all plugins are unloaded, the catalog is cleared, and the // function returns an error. -func Load(ctx context.Context, config Config, cat Catalog) (_ io.Closer, err error) { +func Load(ctx context.Context, config Config, repo Repository) (_ *Catalog, err error) { closers := make(closerGroup, 0) defer func() { // If loading fails, clear out the catalog and close down all plugins // that have been loaded thus far. if err != nil { - for _, pluginRepo := range cat.Plugins() { + for _, pluginRepo := range repo.Plugins() { pluginRepo.Clear() } - for _, serviceRepo := range cat.Services() { + for _, serviceRepo := range repo.Services() { serviceRepo.Clear() } closers.Close() } }() - pluginRepos, err := makeBindablePluginRepos(cat.Plugins()) + pluginRepos, err := makeBindablePluginRepos(repo.Plugins()) if err != nil { return nil, err } - serviceRepos, err := makeBindableServiceRepos(cat.Services()) + serviceRepos, err := makeBindableServiceRepos(repo.Services()) if err != nil { return nil, err } pluginCounts := make(map[string]int) + var reconfigurers Reconfigurers for _, pluginConfig := range config.PluginConfigs { pluginLog := makePluginLog(config.Log, pluginConfig) @@ -175,15 +189,13 @@ func Load(ctx context.Context, config Config, cat Catalog) (_ io.Closer, err err return nil, fmt.Errorf("failed to bind plugin %q: %w", pluginConfig.Name, err) } - switch { - case configurer != nil: - if err := configurer.Configure(ctx, config.CoreConfig, pluginConfig.Data); err != nil { - pluginLog.WithError(err).Error("Failed to configure plugin") - return nil, fmt.Errorf("failed to configure plugin %q: %w", pluginConfig.Name, err) - } - case pluginConfig.Data != "": - pluginLog.WithField(telemetry.Reason, "no supported configuration interface").Error("Failed to configure plugin") - return nil, fmt.Errorf("failed to configure plugin %q: no supported configuration interface found", pluginConfig.Name) + reconfigurer, err := configurePlugin(ctx, pluginLog, config.CoreConfig, configurer, pluginConfig.DataSource) + if err != nil { + pluginLog.WithError(err).Error("Failed to configure plugin") + return nil, fmt.Errorf("failed to configure plugin %q: %w", pluginConfig.Name, err) + } + if reconfigurer != nil { + reconfigurers = append(reconfigurers, reconfigurer) } pluginLog.Info("Plugin loaded") @@ -197,7 +209,10 @@ func Load(ctx context.Context, config Config, cat Catalog) (_ io.Closer, err err } } - return closers, nil + return &Catalog{ + closers: closers, + reconfigurers: reconfigurers, + }, nil } func makePluginLog(log logrus.FieldLogger, pluginConfig PluginConfig) logrus.FieldLogger { diff --git a/pkg/common/catalog/catalog_test.go b/pkg/common/catalog/catalog_test.go index 0689f4e300..badc35061e 100644 --- a/pkg/common/catalog/catalog_test.go +++ b/pkg/common/catalog/catalog_test.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strings" "testing" "time" @@ -104,6 +105,8 @@ type loadTest struct { expectErr string expectPluginClient bool expectServiceClient bool + expectLogEntries []spiretest.LogEntry + epilogue func(t *testing.T, cat *catalog.Catalog) } func testPlugin(t *testing.T, pluginPath string) { @@ -213,21 +216,54 @@ func testPlugin(t *testing.T, pluginPath string) { }, }) }) - t.Run("configure success", func(t *testing.T) { + t.Run("configure from fixed success", func(t *testing.T) { testLoad(t, pluginPath, loadTest{ registerConfigService: true, mutateConfig: func(config *catalog.Config) { - config.PluginConfigs[0].Data = "GOOD" + config.PluginConfigs[0].DataSource = catalog.FixedData("GOOD") }, expectPluginClient: true, expectServiceClient: true, }) }) + t.Run("configure and reconfigure from file success", func(t *testing.T) { + configPath := filepath.Join(spiretest.TempDir(t), "plugin.conf") + require.NoError(t, os.WriteFile(configPath, []byte("GOOD1"), 0600)) + + testLoad(t, pluginPath, loadTest{ + registerConfigService: true, + mutateConfig: func(config *catalog.Config) { + config.PluginConfigs[0].DataSource = catalog.FileData(configPath) + }, + expectPluginClient: true, + expectServiceClient: true, + expectLogEntries: []spiretest.LogEntry{ + { + Level: logrus.InfoLevel, + Message: "CONFIGURED", + Data: logrus.Fields{ + "config": "GOOD1", + }, + }, + { + Level: logrus.InfoLevel, + Message: "CONFIGURED", + Data: logrus.Fields{ + "config": "GOOD2", + }, + }, + }, + epilogue: func(t *testing.T, cat *catalog.Catalog) { + require.NoError(t, os.WriteFile(configPath, []byte("GOOD2"), 0600)) + cat.Reconfigure(context.Background()) + }, + }) + }) t.Run("configure failure", func(t *testing.T) { testLoad(t, pluginPath, loadTest{ registerConfigService: true, mutateConfig: func(config *catalog.Config) { - config.PluginConfigs[0].Data = "BAD" + config.PluginConfigs[0].DataSource = catalog.FixedData("BAD") }, expectErr: `failed to configure plugin "test": rpc error: code = InvalidArgument desc = bad config`, }) @@ -235,7 +271,7 @@ func testPlugin(t *testing.T, pluginPath string) { t.Run("configure interface not registered but data supplied", func(t *testing.T) { testLoad(t, pluginPath, loadTest{ mutateConfig: func(config *catalog.Config) { - config.PluginConfigs[0].Data = "GOOD" + config.PluginConfigs[0].DataSource = catalog.FixedData("GOOD") }, expectErr: `failed to configure plugin "test": no supported configuration interface found`, }) @@ -355,26 +391,53 @@ func testLoad(t *testing.T, pluginPath string, tt loadTest) { tt.mutateServiceRepo(serviceRepo) } - closer, err := catalog.Load(context.Background(), config, repo) - if closer != nil { + cat, err := catalog.Load(context.Background(), config, repo) + if cat != nil { defer func() { - closer.Close() + cat.Close() + + wantEntries := slices.Clone(tt.expectLogEntries) if tt.expectPluginClient { // Assert that the plugin io.Closer was invoked by looking at // the logs. It's hard to use the full log entry since there // is a bunch of unrelated, per-test-run type stuff in there, // so just inspect the log messages. - assertContainsLogMessage(t, hook.AllEntries(), "CLOSED") + + wantEntries = append(wantEntries, spiretest.LogEntry{ + Level: logrus.InfoLevel, + Message: "CLOSED", + }) + } + + // Prune out data that isn't contained in the wanted entries. + // Otherwise, the tests get pretty coupled to the log fields, which + // isn't what these tests are particularly concerned with. + wantData := make(map[string]bool) + for _, wantEntry := range wantEntries { + for k := range wantEntry.Data { + wantData[k] = true + } + } + var allEntries []*logrus.Entry + for _, entry := range hook.AllEntries() { + // Only keep fields that are present in the wanted entries + for k := range entry.Data { + if !wantData[k] { + delete(entry.Data, k) + } + } + allEntries = append(allEntries, entry) } + spiretest.AssertLogsContainEntries(t, allEntries, wantEntries) }() } if tt.expectErr != "" { require.ErrorContains(t, err, tt.expectErr, "load should have failed") - assert.Nil(t, closer, "closer should have been nil") + assert.Nil(t, cat, "catalog should have been nil") } else { require.NoError(t, err, "load should not have failed") - assert.NotNil(t, closer, "closer should not have been nil") + assert.NotNil(t, cat, "catalog should not have been nil") } if tt.expectPluginClient { @@ -410,6 +473,10 @@ func testLoad(t *testing.T, pluginPath string, tt loadTest) { } else { assert.Nil(t, someService, "service client should not have been initialized") } + + if tt.epilogue != nil { + tt.epilogue(t, cat) + } } func buildTestPlugin(t *testing.T, srcPath string) string { @@ -566,11 +633,3 @@ func (badFacade) GRPCServiceName() string { return "bad" } func (badFacade) InitClient(grpc.ClientConnInterface) any { return nil } func (badFacade) InitInfo(catalog.PluginInfo) {} func (badFacade) InitLog(logrus.FieldLogger) {} - -func assertContainsLogMessage(t *testing.T, entries []*logrus.Entry, message string) { - messages := make([]string, 0, len(entries)) - for _, entry := range entries { - messages = append(messages, entry.Message) - } - assert.Contains(t, messages, message) -} diff --git a/pkg/common/catalog/config.go b/pkg/common/catalog/config.go index 5e8a1d3297..2d61d229ea 100644 --- a/pkg/common/catalog/config.go +++ b/pkg/common/catalog/config.go @@ -2,7 +2,9 @@ package catalog import ( "bytes" + "errors" "fmt" + "os" "github.com/hashicorp/hcl" "github.com/hashicorp/hcl/hcl/ast" @@ -33,13 +35,13 @@ func (cs PluginConfigs) Find(pluginType, pluginName string) (PluginConfig, bool) } type PluginConfig struct { - Type string - Name string - Path string - Args []string - Checksum string - Data string - Disabled bool + Type string + Name string + Path string + Args []string + Checksum string + DataSource DataSource + Disabled bool } func (c PluginConfig) IsEnabled() bool { @@ -50,11 +52,41 @@ func (c *PluginConfig) IsExternal() bool { return c.Path != "" } +type DataSource interface { + Load() (string, error) + IsDynamic() bool +} + +type FixedData string + +func (d FixedData) Load() (string, error) { + return string(d), nil +} + +func (d FixedData) IsDynamic() bool { + return false +} + +type FileData string + +func (d FileData) Load() (string, error) { + data, err := os.ReadFile(string(d)) + if err != nil { + return "", err + } + return string(data), nil +} + +func (d FileData) IsDynamic() bool { + return true +} + type hclPluginConfig struct { PluginCmd string `hcl:"plugin_cmd"` PluginArgs []string `hcl:"plugin_args"` PluginChecksum string `hcl:"plugin_checksum"` PluginData ast.Node `hcl:"plugin_data"` + PluginDataFile *string `hcl:"plugin_data_file"` Enabled *bool `hcl:"enabled"` } @@ -218,21 +250,34 @@ func (m pluginsMapList) Len() int { } func pluginConfigFromHCL(pluginType, pluginName string, hclPluginConfig hclPluginConfig) (PluginConfig, error) { - var data bytes.Buffer + if hclPluginConfig.PluginData != nil && hclPluginConfig.PluginDataFile != nil { + return PluginConfig{}, errors.New("only one of [plugin_data, plugin_data_file] can be used") + } + + var dataSource DataSource + if hclPluginConfig.PluginData != nil { - if err := printer.DefaultConfig.Fprint(&data, hclPluginConfig.PluginData); err != nil { + var buf bytes.Buffer + if err := printer.DefaultConfig.Fprint(&buf, hclPluginConfig.PluginData); err != nil { return PluginConfig{}, err } + if data := buf.String(); data != "" { + dataSource = FixedData(data) + } + } + + if hclPluginConfig.PluginDataFile != nil { + dataSource = FileData(*hclPluginConfig.PluginDataFile) } return PluginConfig{ - Name: pluginName, - Type: pluginType, - Path: hclPluginConfig.PluginCmd, - Args: hclPluginConfig.PluginArgs, - Checksum: hclPluginConfig.PluginChecksum, - Data: data.String(), - Disabled: !hclPluginConfig.IsEnabled(), + Name: pluginName, + Type: pluginType, + Path: hclPluginConfig.PluginCmd, + Args: hclPluginConfig.PluginArgs, + Checksum: hclPluginConfig.PluginChecksum, + DataSource: dataSource, + Disabled: !hclPluginConfig.IsEnabled(), }, nil } diff --git a/pkg/common/catalog/config_test.go b/pkg/common/catalog/config_test.go index df3f5fe17e..538098a335 100644 --- a/pkg/common/catalog/config_test.go +++ b/pkg/common/catalog/config_test.go @@ -24,43 +24,54 @@ func TestParsePluginConfigsFromHCLNode(t *testing.T) { require.NoError(t, err) pluginA := PluginConfig{ - Name: "NAME3", - Type: "TYPE1", - Data: `"DATA3"`, - Disabled: true, + Name: "NAME3", + Type: "TYPE1", + DataSource: FixedData(`"DATA3"`), + Disabled: true, } pluginB := PluginConfig{ Name: "NAME4", Type: "TYPE4", } pluginC := PluginConfig{ - Name: "NAME1", - Type: "TYPE1", - Path: "CMD1", - Data: `"DATA1"`, - Disabled: false, + Name: "NAME1", + Type: "TYPE1", + Path: "CMD1", + DataSource: FixedData(`"DATA1"`), + Disabled: false, } pluginD := PluginConfig{ - Name: "NAME5", - Type: "TYPE1", - Data: `"foo" = "bar"`, - Disabled: false, + Name: "NAME5", + Type: "TYPE1", + DataSource: FixedData(`"foo" = "bar"`), + Disabled: false, } pluginE := PluginConfig{ - Name: "NAME2", - Type: "TYPE2", - Path: "CMD2", - Args: []string{"foo", "bar", "baz"}, - Checksum: "CHECKSUM2", - Data: `"DATA2"`, - Disabled: false, + Name: "NAME2", + Type: "TYPE2", + Path: "CMD2", + Args: []string{"foo", "bar", "baz"}, + Checksum: "CHECKSUM2", + DataSource: FixedData(`"DATA2"`), + Disabled: false, } pluginF := PluginConfig{ - Name: "NAME6", - Type: "TYPE3", - Data: `"foo" = "bar"`, - Disabled: false, + Name: "NAME6", + Type: "TYPE3", + DataSource: FixedData(`"foo" = "bar"`), + Disabled: false, } + pluginG := PluginConfig{ + Name: "NAME7", + Type: "TYPE5", + DataSource: FileData("FILE7"), + } + pluginH := PluginConfig{ + Name: "NAME8", + Type: "TYPE5", + DataSource: nil, + } + // The declaration order should be preserved. require.Equal(t, PluginConfigs{ pluginA, @@ -69,6 +80,8 @@ func TestParsePluginConfigsFromHCLNode(t *testing.T) { pluginD, pluginE, pluginF, + pluginG, + pluginH, }, configs) // A, C, and D are of type TYPE1 @@ -84,6 +97,8 @@ func TestParsePluginConfigsFromHCLNode(t *testing.T) { pluginB, pluginE, pluginF, + pluginG, + pluginH, }, remaining) c, ok := configs.Find("TYPE1", "NAME1") @@ -125,6 +140,12 @@ func TestParsePluginConfigsFromHCLNode(t *testing.T) { TYPE3 "NAME6" "plugin_data" { "foo" = "bar" } + TYPE5 "NAME7" { + plugin_data_file = "FILE7" + } + TYPE5 "NAME8" { + plugin_data = {} + } } ` test(t, config) @@ -190,6 +211,18 @@ func TestParsePluginConfigsFromHCLNode(t *testing.T) { } } ], + "TYPE5": [ + { + "NAME7": { + "plugin_data_file": "FILE7" + } + }, + { + "NAME8": { + "plugin_data": {} + } + } + ], } }` test(t, config) @@ -219,4 +252,23 @@ func TestParsePluginConfigsFromHCLNode(t *testing.T) { _, err = PluginConfigsFromHCLNode(root.Plugins) require.EqualError(t, err, `plugin "TYPE"/"NAME" declared more than once`) }) + + t.Run("Both plugin_data and plugin_data_file are declared", func(t *testing.T) { + config := ` + plugins { + TYPE "NAME" { + plugin_data = "DATA" + plugin_data_file = "DATAFILE" + } + } + ` + root := struct { + Plugins ast.Node `hcl:"plugins"` + }{} + err := hcl.Decode(&root, config) + require.NoError(t, err) + + _, err = PluginConfigsFromHCLNode(root.Plugins) + require.EqualError(t, err, `failed to create plugin config for "TYPE"/"NAME": only one of [plugin_data, plugin_data_file] can be used`) + }) } diff --git a/pkg/common/catalog/configure.go b/pkg/common/catalog/configure.go index d7bee282cd..d31660c4f6 100644 --- a/pkg/common/catalog/configure.go +++ b/pkg/common/catalog/configure.go @@ -2,10 +2,17 @@ package catalog import ( "context" + "crypto/sha512" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" "github.com/sirupsen/logrus" "github.com/spiffe/go-spiffe/v2/spiffeid" configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/common/telemetry" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -24,6 +31,99 @@ type Configurer interface { Configure(ctx context.Context, coreConfig CoreConfig, configuration string) error } +type ConfigurerFunc func(ctx context.Context, coreConfig CoreConfig, configuration string) error + +func (fn ConfigurerFunc) Configure(ctx context.Context, coreConfig CoreConfig, configuration string) error { + return fn(ctx, coreConfig, configuration) +} + +func ConfigurePlugin(ctx context.Context, coreConfig CoreConfig, configurer Configurer, dataSource DataSource, lastHash string) (string, error) { + data, err := dataSource.Load() + if err != nil { + return "", fmt.Errorf("failed to load plugin data: %w", err) + } + + dataHash := hashData(data) + if lastHash == "" || dataHash != lastHash { + if err := configurer.Configure(ctx, coreConfig, data); err != nil { + return "", err + } + } + return dataHash, nil +} + +func ReconfigureTask(log logrus.FieldLogger, reconfigurer Reconfigurer) func(context.Context) error { + return func(ctx context.Context) error { + return ReconfigureOnSignal(ctx, log, reconfigurer) + } +} + +type Reconfigurer interface { + Reconfigure(ctx context.Context) +} + +type Reconfigurers []Reconfigurer + +func (rs Reconfigurers) Reconfigure(ctx context.Context) { + for _, r := range rs { + r.Reconfigure(ctx) + } +} + +type Reconfigurable struct { + Log logrus.FieldLogger + CoreConfig CoreConfig + Configurer Configurer + DataSource DataSource + LastHash string +} + +func (r *Reconfigurable) Reconfigure(ctx context.Context) { + if dataHash, err := ConfigurePlugin(ctx, r.CoreConfig, r.Configurer, r.DataSource, r.LastHash); err != nil { + r.Log.WithError(err).Error("Failed to reconfigure plugin") + } else if dataHash == r.LastHash { + r.Log.WithField(telemetry.Hash, r.LastHash).Info("Plugin not reconfigured since the config is unchanged") + } else { + r.Log.WithField(telemetry.OldHash, r.LastHash).WithField(telemetry.NewHash, dataHash).Info("Plugin reconfigured") + r.LastHash = dataHash + } +} + +func configurePlugin(ctx context.Context, pluginLog logrus.FieldLogger, coreConfig CoreConfig, configurer Configurer, dataSource DataSource) (Reconfigurer, error) { + switch { + case configurer == nil && dataSource == nil: + // The plugin doesn't support configuration and no data source was configured. Nothing to do. + return nil, nil + case configurer == nil && dataSource != nil: + // The plugin does not support configuration but a data source was configured. This is a failure. + return nil, errors.New("no supported configuration interface found") + case configurer != nil && dataSource == nil: + // The plugin supports configuration but no data source was configured. Default to an empty, fixed configuration. + dataSource = FixedData("") + case configurer != nil && dataSource != nil: + // The plugin supports configuration and there was a data source. + } + + dataHash, err := ConfigurePlugin(ctx, coreConfig, configurer, dataSource, "") + if err != nil { + return nil, err + } + + if !dataSource.IsDynamic() { + pluginLog.WithField(telemetry.Reconfigurable, false).Info("Configured plugin") + return nil, nil + } + + pluginLog.WithField(telemetry.Reconfigurable, true).WithField(telemetry.Hash, dataHash).Info("Configured plugin") + return &Reconfigurable{ + Log: pluginLog, + CoreConfig: coreConfig, + Configurer: configurer, + DataSource: dataSource, + LastHash: dataHash, + }, nil +} + type configurerRepo struct { configurer Configurer } @@ -77,3 +177,9 @@ type configurerUnsupported struct{} func (c configurerUnsupported) Configure(context.Context, CoreConfig, string) error { return status.Error(codes.FailedPrecondition, "plugin does not support a configuration interface") } + +func hashData(data string) string { + h := sha512.New() + _, _ = io.Copy(h, strings.NewReader(data)) + return hex.EncodeToString(h.Sum(nil)[:16]) +} diff --git a/pkg/common/catalog/configure_posix.go b/pkg/common/catalog/configure_posix.go new file mode 100644 index 0000000000..11dcdc8e95 --- /dev/null +++ b/pkg/common/catalog/configure_posix.go @@ -0,0 +1,28 @@ +//go:build !windows + +package catalog + +import ( + "context" + "os" + "os/signal" + + "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" +) + +func ReconfigureOnSignal(ctx context.Context, log logrus.FieldLogger, reconfigurer Reconfigurer) error { + ch := make(chan os.Signal, 1) + signal.Notify(ch, unix.SIGUSR1) + defer signal.Stop(ch) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ch: + log.Info("Reconfigure signal received") + reconfigurer.Reconfigure(ctx) + } + } +} diff --git a/pkg/common/catalog/configure_windows.go b/pkg/common/catalog/configure_windows.go new file mode 100644 index 0000000000..43df600754 --- /dev/null +++ b/pkg/common/catalog/configure_windows.go @@ -0,0 +1,13 @@ +package catalog + +import ( + "context" + + "github.com/sirupsen/logrus" +) + +func ReconfigureOnSignal(ctx context.Context, _ logrus.FieldLogger, _ Reconfigurer) error { + // TODO: maybe drive this using an event? + <-ctx.Done() + return ctx.Err() +} diff --git a/pkg/common/catalog/testplugin/plugin.go b/pkg/common/catalog/testplugin/plugin.go index dbbb02c87b..1bd6ccc769 100644 --- a/pkg/common/catalog/testplugin/plugin.go +++ b/pkg/common/catalog/testplugin/plugin.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/hashicorp/go-hclog" "github.com/spiffe/spire-plugin-sdk/pluginsdk" @@ -65,11 +66,11 @@ func (p *Plugin) ServiceEcho(ctx context.Context, req *test.EchoRequest) (*test. } func (p *Plugin) Configure(_ context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { - p.log.Info("CONFIGURED") + p.log.Info("CONFIGURED", "config", req.HclConfiguration) if req.CoreConfiguration.TrustDomain != "example.org" { return nil, status.Errorf(codes.InvalidArgument, "expected trust domain %q; got %q", "example.org", req.CoreConfiguration.TrustDomain) } - if req.HclConfiguration != "GOOD" { + if !strings.HasPrefix(req.HclConfiguration, "GOOD") { return nil, status.Error(codes.InvalidArgument, "bad config") } return &configv1.ConfigureResponse{}, nil diff --git a/pkg/common/telemetry/names.go b/pkg/common/telemetry/names.go index abeee56c4a..b49daa4003 100644 --- a/pkg/common/telemetry/names.go +++ b/pkg/common/telemetry/names.go @@ -332,6 +332,9 @@ const ( // Generation represents an objection generation (i.e. version) Generation = "generation" + // Hash tags a hash + Hash = "hash" + // Hint tags registration entry hint Hint = "hint" @@ -375,6 +378,9 @@ const ( // Network tags some network name ("tcp", "udp") Network = "network" + // NewHash tags a new hash + NewHash = "new_hash" + // NewSerialNumber tags a certificate new serial number NewSerialNumber = "new_serial_num" @@ -384,6 +390,9 @@ const ( // Nonce tags some nonce for communication Nonce = "nonce" + // OldHash tags a hash + OldHash = "old_hash" + // ParentID tags parent ID for an entry ParentID = "parent_id" @@ -445,6 +454,9 @@ const ( // RecordMapSize is the gauge key to hold the size of the LRU cache entries map RecordMapSize = "lru_cache_record_map_size" + // Reconfigurable tags whether or not something is reconfigurable. + Reconfigurable = "reconfigurable" + // RefreshHint tags a bundle refresh hint RefreshHint = "refresh_hint" diff --git a/pkg/server/catalog/catalog.go b/pkg/server/catalog/catalog.go index fe01f4ba10..96deacfeb1 100644 --- a/pkg/server/catalog/catalog.go +++ b/pkg/server/catalog/catalog.go @@ -43,6 +43,8 @@ const ( upstreamAuthorityType = "UpstreamAuthority" ) +var ReconfigureTask = catalog.ReconfigureTask + type Catalog interface { GetBundlePublishers() []bundlepublisher.BundlePublisher GetCredentialComposers() []credentialcomposer.CredentialComposer @@ -77,9 +79,9 @@ type Repository struct { notifierRepository upstreamAuthorityRepository - log logrus.FieldLogger - dataStoreCloser io.Closer - catalogCloser io.Closer + log logrus.FieldLogger + dsCloser io.Closer + catalog *catalog.Catalog } func (repo *Repository) Plugins() map[string]catalog.PluginRepo { @@ -97,21 +99,25 @@ func (repo *Repository) Services() []catalog.ServiceRepo { return nil } +func (repo *Repository) Reconfigure(ctx context.Context) { + repo.catalog.Reconfigure(ctx) +} + func (repo *Repository) Close() { // Must close in reverse initialization order! - if repo.catalogCloser != nil { + if repo.catalog != nil { repo.log.Debug("Closing catalog") - if err := repo.catalogCloser.Close(); err == nil { + if err := repo.catalog.Close(); err == nil { repo.log.Info("Catalog closed") } else { repo.log.WithError(err).Error("Failed to close catalog") } } - if repo.dataStoreCloser != nil { + if repo.dsCloser != nil { repo.log.Debug("Closing DataStore") - if err := repo.dataStoreCloser.Close(); err == nil { + if err := repo.dsCloser.Close(); err == nil { repo.log.Info("DataStore closed") } else { repo.log.WithError(err).Error("Failed to close DataStore") @@ -133,20 +139,22 @@ func Load(ctx context.Context, config Config) (_ *Repository, err error) { } }() + coreConfig := catalog.CoreConfig{ + TrustDomain: config.TrustDomain, + } + // Strip out the Datastore plugin configuration and load the SQL plugin // directly. This allows us to bypass gRPC and get rid of response limits. dataStoreConfigs, pluginConfigs := config.PluginConfigs.FilterByType(dataStoreType) - sqlDataStore, err := loadSQLDataStore(ctx, config, dataStoreConfigs) + sqlDataStore, err := loadSQLDataStore(ctx, config, coreConfig, dataStoreConfigs) if err != nil { return nil, err } - repo.dataStoreCloser = sqlDataStore + repo.dsCloser = sqlDataStore - repo.catalogCloser, err = catalog.Load(ctx, catalog.Config{ - Log: config.Log, - CoreConfig: catalog.CoreConfig{ - TrustDomain: config.TrustDomain, - }, + repo.catalog, err = catalog.Load(ctx, catalog.Config{ + Log: config.Log, + CoreConfig: coreConfig, PluginConfigs: pluginConfigs, HostServices: []pluginsdk.ServiceServer{ identityproviderv1.IdentityProviderServiceServer(config.IdentityProvider.V1()), @@ -172,7 +180,7 @@ func Load(ctx context.Context, config Config) (_ *Repository, err error) { return repo, nil } -func loadSQLDataStore(ctx context.Context, config Config, datastoreConfigs catalog.PluginConfigs) (*ds_sql.Plugin, error) { +func loadSQLDataStore(ctx context.Context, config Config, coreConfig catalog.CoreConfig, datastoreConfigs catalog.PluginConfigs) (*ds_sql.Plugin, error) { switch { case len(datastoreConfigs) == 0: return nil, errors.New("expecting a DataStore plugin") @@ -188,10 +196,24 @@ func loadSQLDataStore(ctx context.Context, config Config, datastoreConfigs catal if sqlConfig.IsExternal() { return nil, fmt.Errorf("pluggability for the DataStore is deprecated; only the built-in %q plugin is supported", ds_sql.PluginName) } + if sqlConfig.DataSource == nil { + sqlConfig.DataSource = catalog.FixedData("") + } + + dsLog := config.Log.WithField(telemetry.SubsystemName, sqlConfig.Name) + ds := ds_sql.New(dsLog) + configurer := catalog.ConfigurerFunc(func(ctx context.Context, _ catalog.CoreConfig, configuration string) error { + return ds.Configure(ctx, configuration) + }) - ds := ds_sql.New(config.Log.WithField(telemetry.SubsystemName, sqlConfig.Name)) - if err := ds.Configure(ctx, sqlConfig.Data); err != nil { + if _, err := catalog.ConfigurePlugin(ctx, coreConfig, configurer, sqlConfig.DataSource, ""); err != nil { return nil, err } + + if sqlConfig.DataSource.IsDynamic() { + config.Log.Warn("DataStore is not reconfigurable even with a dynamic data source") + } + + config.Log.WithField(telemetry.Reconfigurable, false).Info("Configured DataStore") return ds, nil } diff --git a/pkg/server/catalog/catalog_test.go b/pkg/server/catalog/catalog_test.go index 47c9dece7a..3358ee336b 100644 --- a/pkg/server/catalog/catalog_test.go +++ b/pkg/server/catalog/catalog_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/sirupsen/logrus/hooks/test" + commoncatalog "github.com/spiffe/spire/pkg/common/catalog" "github.com/spiffe/spire/pkg/common/health" "github.com/spiffe/spire/pkg/server/catalog" "github.com/spiffe/spire/test/spiretest" @@ -54,10 +55,10 @@ func Test(t *testing.T) { { Type: "DataStore", Name: "sql", - Data: fmt.Sprintf(` + DataSource: commoncatalog.FixedData(fmt.Sprintf(` database_type = "sqlite3" connection_string = %q - `, filepath.Join(dir, "test.sql")), + `, filepath.Join(dir, "test.sql"))), }, { Type: "KeyManager", diff --git a/pkg/server/server.go b/pkg/server/server.go index a1c7368355..d860b286bb 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -208,6 +208,7 @@ func (s *Server) run(ctx context.Context) (err error) { bundleManager.Run, registrationManager.Run, bundlePublishingManager.Run, + catalog.ReconfigureTask(s.config.Log.WithField(telemetry.SubsystemName, "reconfigurer"), cat), util.SerialRun(s.waitForTestDial, healthChecker.ListenAndServe), } diff --git a/test/fixture/config/agent_good_posix.conf b/test/fixture/config/agent_good_posix.conf index b600ddaee7..716bd9674f 100644 --- a/test/fixture/config/agent_good_posix.conf +++ b/test/fixture/config/agent_good_posix.conf @@ -32,8 +32,6 @@ plugins { plugin_cmd = "./pluginAgentCmd" enabled = true plugin_checksum = "pluginAgentChecksum" - plugin_data { - join_token = "PLUGIN-AGENT-NOT-A-SECRET" - } + plugin_data_file = "plugin.conf" } } diff --git a/test/fixture/config/agent_good_windows.conf b/test/fixture/config/agent_good_windows.conf index 0897ebdd26..6a873b2b26 100644 --- a/test/fixture/config/agent_good_windows.conf +++ b/test/fixture/config/agent_good_windows.conf @@ -35,8 +35,6 @@ plugins { plugin_cmd = "c:/temp/pluginAgentCmd" enabled = true plugin_checksum = "pluginAgentChecksum" - plugin_data { - join_token = "PLUGIN-AGENT-NOT-A-SECRET" - } + plugin_data_file = "plugin.conf" } } diff --git a/test/fixture/config/server_good_posix.conf b/test/fixture/config/server_good_posix.conf index 7981d4007c..ae273f4c95 100644 --- a/test/fixture/config/server_good_posix.conf +++ b/test/fixture/config/server_good_posix.conf @@ -58,8 +58,6 @@ plugins { plugin_cmd = "./pluginServerCmd" enabled = true plugin_checksum = "pluginServerChecksum" - plugin_data { - join_token = "PLUGIN-SERVER-NOT-A-SECRET" - } + plugin_data_file = "plugin.conf" } } diff --git a/test/fixture/config/server_good_windows.conf b/test/fixture/config/server_good_windows.conf index 1422ef39db..3accbbc1ef 100644 --- a/test/fixture/config/server_good_windows.conf +++ b/test/fixture/config/server_good_windows.conf @@ -60,8 +60,6 @@ plugins { plugin_cmd = "./pluginServerCmd" enabled = true plugin_checksum = "pluginServerChecksum" - plugin_data { - join_token = "PLUGIN-SERVER-NOT-A-SECRET" - } + plugin_data_file = "plugin.conf" } }