diff --git a/cmd/botkube/main.go b/cmd/botkube/main.go index 18890c69a..fa819c46c 100644 --- a/cmd/botkube/main.go +++ b/cmd/botkube/main.go @@ -27,6 +27,7 @@ import ( "github.com/kubeshop/botkube/internal/analytics" "github.com/kubeshop/botkube/internal/audit" "github.com/kubeshop/botkube/internal/command" + intconfig "github.com/kubeshop/botkube/internal/config" "github.com/kubeshop/botkube/internal/graphql" "github.com/kubeshop/botkube/internal/lifecycle" "github.com/kubeshop/botkube/internal/loggerx" @@ -48,12 +49,13 @@ import ( ) const ( - componentLogFieldKey = "component" - botLogFieldKey = "bot" - sinkLogFieldKey = "sink" - commGroupFieldKey = "commGroup" - healthEndpointName = "/healthz" - printAPIKeyCharCount = 3 + componentLogFieldKey = "component" + botLogFieldKey = "bot" + sinkLogFieldKey = "sink" + commGroupFieldKey = "commGroup" + healthEndpointName = "/healthz" + printAPIKeyCharCount = 3 + configUpdaterInterval = 15 * time.Second ) func main() { @@ -70,10 +72,14 @@ func main() { // run wraps the main logic of the app to be able to properly clean up resources via deferred calls. func run(ctx context.Context) error { // Load configuration - config.RegisterFlags(pflag.CommandLine) + intconfig.RegisterFlags(pflag.CommandLine) + + remoteCfgSyncEnabled := graphql.IsRemoteConfigEnabled() gqlClient := graphql.NewDefaultGqlClient() - cfgProvider := config.GetProvider(gqlClient) - configs, err := cfgProvider.Configs(ctx) + deployClient := intconfig.NewDeploymentClient(gqlClient) + + cfgProvider := intconfig.GetProvider(remoteCfgSyncEnabled, deployClient) + configs, cfgVersion, err := cfgProvider.Configs(ctx) if err != nil { return fmt.Errorf("while loading configuration files: %w", err) } @@ -84,13 +90,13 @@ func run(ctx context.Context) error { } logger := loggerx.New(conf.Settings.Log) - statusReporter := status.NewStatusReporter(logger, gqlClient) - auditReporter := audit.NewAuditReporter(logger, gqlClient) - if confDetails.ValidateWarnings != nil { logger.Warnf("Configuration validation warnings: %v", confDetails.ValidateWarnings.Error()) } + statusReporter := status.NewStatusReporter(remoteCfgSyncEnabled, logger, gqlClient, deployClient, cfgVersion) + auditReporter := audit.NewAuditReporter(remoteCfgSyncEnabled, logger, gqlClient) + // Set up analytics reporter reporter, err := newAnalyticsReporter(conf.Analytics.Disable, logger) if err != nil { @@ -302,6 +308,18 @@ func run(ctx context.Context) error { }) } + cfgUpdater := intconfig.GetConfigUpdater( + remoteCfgSyncEnabled, + logger.WithField(componentLogFieldKey, "Config Updater"), + configUpdaterInterval, + deployClient, + statusReporter, + ) + errGroup.Go(func() error { + defer analytics.ReportPanicIfOccurs(logger, reporter) + return cfgUpdater.Do(ctx) + }) + if conf.ConfigWatcher.Enabled { err := config.WaitForWatcherSync( ctx, @@ -358,7 +376,7 @@ func run(ctx context.Context) error { statusReporter, ) - if _, err := statusReporter.ReportDeploymentStartup(ctx); err != nil { + if err := statusReporter.ReportDeploymentStartup(ctx); err != nil { return reportFatalError("while reporting botkube startup", err) } @@ -462,8 +480,8 @@ func reportFatalErrFn(logger logrus.FieldLogger, reporter analytics.Reporter, st logger.Errorf("while reporting fatal error: %s", err.Error()) } - if _, err := status.ReportDeploymentFailed(ctxTimeout); err != nil { - logger.Errorf("while reporting botkube deployment status: %s", err.Error()) + if err := status.ReportDeploymentFailed(ctxTimeout); err != nil { + logger.Errorf("while reporting deployment failure: %s", err.Error()) } return wrappedErr diff --git a/go.mod b/go.mod index 33dc0c2b9..9365d69b5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/kubeshop/botkube require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/alexflint/go-arg v1.4.3 + github.com/avast/retry-go v3.0.0+incompatible github.com/aws/aws-sdk-go v1.44.122 github.com/bwmarrin/discordgo v0.25.0 github.com/dustin/go-humanize v1.0.0 @@ -51,7 +52,6 @@ require ( k8s.io/kubectl v0.25.4 k8s.io/utils v0.0.0-20221108210102-8e77b1f39fe2 sigs.k8s.io/controller-runtime v0.13.1 - sigs.k8s.io/yaml v1.3.0 ) require ( @@ -196,6 +196,7 @@ require ( sigs.k8s.io/kustomize/api v0.12.1 // indirect sigs.k8s.io/kustomize/kyaml v0.13.9 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect ) go 1.19 diff --git a/go.sum b/go.sum index 2aaaebb19..7684865da 100644 --- a/go.sum +++ b/go.sum @@ -328,6 +328,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= +github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/avct/uasurfer v0.0.0-20191028135549-26b5daa857f1/go.mod h1:noBAuukeYOXa0aXGqxr24tADqkwDO2KRD15FsuaZ5a8= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= diff --git a/internal/audit/reporter.go b/internal/audit/reporter.go index a294448a1..6891cb62c 100644 --- a/internal/audit/reporter.go +++ b/internal/audit/reporter.go @@ -2,7 +2,6 @@ package audit import ( "context" - "os" "github.com/sirupsen/logrus" @@ -34,8 +33,8 @@ type SourceAuditEvent struct { } // NewAuditReporter creates new AuditReporter -func NewAuditReporter(logger logrus.FieldLogger, gql *graphql.Gql) AuditReporter { - if _, provided := os.LookupEnv(graphql.GqlProviderIdentifierEnvKey); provided { +func NewAuditReporter(remoteCfgSyncEnabled bool, logger logrus.FieldLogger, gql *graphql.Gql) AuditReporter { + if remoteCfgSyncEnabled { return newGraphQLAuditReporter(logger.WithField("component", "GraphQLAuditReporter"), gql) } return newNoopAuditReporter(nil) diff --git a/internal/config/env_provider.go b/internal/config/env_provider.go index e3ae61ea6..4cd358c57 100644 --- a/internal/config/env_provider.go +++ b/internal/config/env_provider.go @@ -4,6 +4,8 @@ import ( "context" "os" "strings" + + "github.com/kubeshop/botkube/pkg/config" ) const ( @@ -20,7 +22,7 @@ func NewEnvProvider() *EnvProvider { } // Configs returns list of config file locations -func (e *EnvProvider) Configs(ctx context.Context) (YAMLFiles, error) { +func (e *EnvProvider) Configs(ctx context.Context) (config.YAMLFiles, int, error) { envCfgs := os.Getenv(EnvProviderConfigPathsEnvKey) configPaths := strings.Split(envCfgs, ",") diff --git a/internal/config/env_provider_test.go b/internal/config/env_provider_test.go index 46e912115..3787846df 100644 --- a/internal/config/env_provider_test.go +++ b/internal/config/env_provider_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestEnvProviderSuccess(t *testing.T) { @@ -14,19 +15,20 @@ func TestEnvProviderSuccess(t *testing.T) { // when p := NewEnvProvider() - configs, err := p.Configs(context.Background()) + configs, cfgVer, err := p.Configs(context.Background()) // then - assert.NoError(t, err) + require.NoError(t, err) content, err := os.ReadFile("testdata/TestEnvProviderSuccess/config.yaml") assert.NoError(t, err) assert.Equal(t, content, configs[0]) + assert.Equal(t, cfgVer, 0) } func TestEnvProviderErr(t *testing.T) { // when p := NewEnvProvider() - _, err := p.Configs(context.Background()) + _, _, err := p.Configs(context.Background()) // then assert.Equal(t, "while reading a file: read .: is a directory", err.Error()) diff --git a/internal/config/flag.go b/internal/config/flag.go new file mode 100644 index 000000000..e21de34dd --- /dev/null +++ b/internal/config/flag.go @@ -0,0 +1,10 @@ +package config + +import "github.com/spf13/pflag" + +var configPathsFlag []string + +// RegisterFlags registers config related flags. +func RegisterFlags(flags *pflag.FlagSet) { + flags.StringSliceVarP(&configPathsFlag, "config", "c", nil, "Specify configuration file in YAML format (can specify multiple).") +} diff --git a/internal/config/fs_provider.go b/internal/config/fs_provider.go index 2d9524ab7..73b929012 100644 --- a/internal/config/fs_provider.go +++ b/internal/config/fs_provider.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/kubeshop/botkube/pkg/config" ) const specialConfigFileNamePrefix = "_" @@ -21,19 +23,19 @@ func NewFileSystemProvider(configs []string) *FileSystemProvider { } // Configs returns list of config file locations. -func (e *FileSystemProvider) Configs(_ context.Context) (YAMLFiles, error) { +func (e *FileSystemProvider) Configs(_ context.Context) (config.YAMLFiles, int, error) { configPaths := sortCfgFiles(e.Files) - var out YAMLFiles + var out config.YAMLFiles for _, path := range configPaths { raw, err := os.ReadFile(filepath.Clean(path)) if err != nil { - return nil, fmt.Errorf("while reading a file: %w", err) + return nil, 0, fmt.Errorf("while reading a file: %w", err) } out = append(out, raw) } - return out, nil + return out, 0, nil } // sortCfgFiles sorts the config files so that the files that has specialConfigFileNamePrefix are moved to the end of the slice. diff --git a/internal/config/fs_provider_test.go b/internal/config/fs_provider_test.go index 8fe2667f7..51c476638 100644 --- a/internal/config/fs_provider_test.go +++ b/internal/config/fs_provider_test.go @@ -6,18 +6,20 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestStaticProviderSuccess(t *testing.T) { // when p := NewFileSystemProvider([]string{"testdata/TestStaticProviderSuccess/config.yaml"}) - configs, err := p.Configs(context.Background()) + configs, cfgVer, err := p.Configs(context.Background()) // then - assert.NoError(t, err) + require.NoError(t, err) content, err := os.ReadFile("testdata/TestStaticProviderSuccess/config.yaml") assert.NoError(t, err) assert.Equal(t, content, configs[0]) + assert.Equal(t, cfgVer, 0) } func TestSortCfgFiles(t *testing.T) { diff --git a/internal/config/gql_client.go b/internal/config/gql_client.go index 75069939d..a5846582e 100644 --- a/internal/config/gql_client.go +++ b/internal/config/gql_client.go @@ -11,7 +11,7 @@ import ( // DeploymentClient defines GraphQL client. type DeploymentClient interface { - GetDeployment(ctx context.Context) (Deployment, error) + GetConfigWithResourceVersion(ctx context.Context) (Deployment, error) } // Gql defines GraphQL client data structure. @@ -27,11 +27,12 @@ func NewDeploymentClient(client *gql.Gql) *Gql { // Deployment returns deployment with Botkube configuration. type Deployment struct { - BotkubeConfig string + ResourceVersion int + YAMLConfig string } -// GetDeployment retrieves deployment by id. -func (g *Gql) GetDeployment(ctx context.Context) (Deployment, error) { +// GetConfigWithResourceVersion retrieves deployment by id. +func (g *Gql) GetConfigWithResourceVersion(ctx context.Context) (Deployment, error) { var query struct { Deployment Deployment `graphql:"deployment(id: $id)"` } @@ -40,7 +41,24 @@ func (g *Gql) GetDeployment(ctx context.Context) (Deployment, error) { } err := g.client.Cli.Query(ctx, &query, variables) if err != nil { - return Deployment{}, fmt.Errorf("while querying deployment details for %q: %w", g.deploymentID, err) + return Deployment{}, fmt.Errorf("while getting config with resource version for %q: %w", g.deploymentID, err) } return query.Deployment, nil } + +// GetResourceVersion retrieves resource version for Deployment. +func (g *Gql) GetResourceVersion(ctx context.Context) (int, error) { + var query struct { + Deployment struct { + ResourceVersion int + } `graphql:"deployment(id: $id)"` + } + variables := map[string]interface{}{ + "id": graphql.ID(g.deploymentID), + } + err := g.client.Cli.Query(ctx, &query, variables) + if err != nil { + return 0, fmt.Errorf("while querying deployment details for %q: %w", g.deploymentID, err) + } + return query.Deployment.ResourceVersion, nil +} diff --git a/internal/config/gql_client_test.go b/internal/config/gql_client_test.go index b006a45c1..474ec28dc 100644 --- a/internal/config/gql_client_test.go +++ b/internal/config/gql_client_test.go @@ -16,7 +16,7 @@ import ( ) func TestGql_GetDeployment(t *testing.T) { - expectedBody := fmt.Sprintf(`{"query":"query ($id:ID!){deployment(id: $id){botkubeConfig}}","variables":{"id":"my-id"}}%s`, "\n") + expectedBody := fmt.Sprintf(`{"query":"query ($id:ID!){deployment(id: $id){resourceVersion,yamlConfig}}","variables":{"id":"my-id"}}%s`, "\n") file, err := os.ReadFile("testdata/gql_get_deployment_success.json") assert.NoError(t, err) svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -49,7 +49,7 @@ func TestGql_GetDeployment(t *testing.T) { graphql.WithDeploymentID("my-id"), ) g := NewDeploymentClient(gqlClient) - deployment, err := g.GetDeployment(context.Background()) + deployment, err := g.GetConfigWithResourceVersion(context.Background()) assert.NoError(t, err) - assert.NotNil(t, deployment.BotkubeConfig) + assert.NotNil(t, deployment.YAMLConfig) } diff --git a/internal/config/gql_provider.go b/internal/config/gql_provider.go index 20dd5dbe6..89bc500fa 100644 --- a/internal/config/gql_provider.go +++ b/internal/config/gql_provider.go @@ -4,7 +4,8 @@ import ( "context" "github.com/pkg/errors" - "sigs.k8s.io/yaml" + + "github.com/kubeshop/botkube/pkg/config" ) // GqlProvider is GraphQL provider @@ -18,17 +19,13 @@ func NewGqlProvider(dc DeploymentClient) *GqlProvider { } // Configs returns list of config files -func (g *GqlProvider) Configs(ctx context.Context) (YAMLFiles, error) { - deployment, err := g.client.GetDeployment(ctx) - if err != nil { - return nil, errors.Wrapf(err, "while getting deployment") - } - conf, err := yaml.JSONToYAML([]byte(deployment.BotkubeConfig)) +func (g *GqlProvider) Configs(ctx context.Context) (config.YAMLFiles, int, error) { + deployment, err := g.client.GetConfigWithResourceVersion(ctx) if err != nil { - return nil, errors.Wrapf(err, "while converting json to yaml for deployment") + return nil, 0, errors.Wrapf(err, "while getting deployment") } return [][]byte{ - conf, - }, nil + []byte(deployment.YAMLConfig), + }, deployment.ResourceVersion, nil } diff --git a/internal/config/gql_provider_test.go b/internal/config/gql_provider_test.go index 29af20b82..4940e2ab9 100644 --- a/internal/config/gql_provider_test.go +++ b/internal/config/gql_provider_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var _ DeploymentClient = &fakeGqlClient{} @@ -13,9 +14,10 @@ var _ DeploymentClient = &fakeGqlClient{} type fakeGqlClient struct { } -func (f *fakeGqlClient) GetDeployment(ctx context.Context) (Deployment, error) { +func (f *fakeGqlClient) GetConfigWithResourceVersion(context.Context) (Deployment, error) { return Deployment{ - BotkubeConfig: "{\"settings\":{\"clusterName\":\"qa\"},\"sources\":{\"kubernetes-info\":{\"displayName\":\"Kubernetes Information\",\"kubernetes\":{\"recommendations\":{\"pod\":{\"noLatestImageTag\":true,\"labelsSet\":true},\"ingress\":{\"backendServiceValid\":true,\"tlsSecretValid\":true}}}}},\"executors\":{\"kubectl-read-only\":{\"kubectl\":{\"namespaces\":{\"include\":[\".*\"]},\"enabled\":true,\"commands\":{\"verbs\":[\"api-resources\",\"api-versions\",\"cluster-info\",\"describe\",\"diff\",\"explain\",\"get\",\"logs\",\"top\",\"auth\"],\"resources\":[\"deployments\",\"pods\",\"namespaces\",\"daemonsets\",\"statefulsets\",\"storageclasses\",\"nodes\"]},\"defaultNamespace\":\"default\",\"restrictAccess\":false}}},\"communications\":{\"default-group\":{\"socketSlack\":{\"enabled\":true,\"channels\":{\"botkube-demo\":{\"name\":\"botkube-demo\",\"notification\":{\"disabled\":false},\"bindings\":{\"sources\":[\"kubernetes-info\"],\"executors\":[\"kubectl-read-only\"]}}},\"botToken\":\"xoxb-3933899240838\",\"appToken\":\"xapp-1-A047D1ZJ03B-4262138376928\"}}}}", + ResourceVersion: 3, + YAMLConfig: "communications:\n default-group:\n socketSlack:\n appToken: xapp-1-A047D1ZJ03B-4262138376928\n botToken: xoxb-3933899240838\n channels:\n botkube-demo:\n bindings:\n executors:\n - kubectl-read-only\n sources:\n - kubernetes-info\n name: botkube-demo\n notification:\n disabled: false\n enabled: true\nexecutors:\n kubectl-read-only:\n kubectl:\n commands:\n resources:\n - deployments\n - pods\n - namespaces\n - daemonsets\n - statefulsets\n - storageclasses\n - nodes\n verbs:\n - api-resources\n - api-versions\n - cluster-info\n - describe\n - diff\n - explain\n - get\n - logs\n - top\n - auth\n defaultNamespace: default\n enabled: true\n namespaces:\n include:\n - .*\n restrictAccess: false\nsettings:\n clusterName: qa\nsources:\n kubernetes-info:\n displayName: Kubernetes Information\n kubernetes:\n recommendations:\n ingress:\n backendServiceValid: true\n tlsSecretValid: true\n pod:\n labelsSet: true\n noLatestImageTag: true\n", }, nil } @@ -23,13 +25,14 @@ func TestGqlProviderSuccess(t *testing.T) { //given f := fakeGqlClient{} p := NewGqlProvider(&f) + expected, err := os.ReadFile("testdata/TestGqlProviderSuccess/config.yaml") + require.NoError(t, err) // when - configs, err := p.Configs(context.Background()) + configs, ver, err := p.Configs(context.Background()) // then assert.NoError(t, err) - content, err := os.ReadFile("testdata/TestGqlProviderSuccess/config.yaml") - assert.NoError(t, err) - assert.Equal(t, configs[0], content) + assert.Equal(t, string(expected), string(configs[0])) + assert.Equal(t, 3, ver) } diff --git a/internal/config/provider.go b/internal/config/provider.go index de89864c3..b063c7dd2 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -1,19 +1,21 @@ package config import ( - "bytes" - "context" + "os" + + "github.com/kubeshop/botkube/pkg/config" ) -// YAMLFiles denotes list of configurations in bytes -type YAMLFiles [][]byte +// GetProvider resolves and returns paths for config files. +// It reads them the 'BOTKUBE_CONFIG_PATHS' env variable. If not found, then it uses '--config' flag. +func GetProvider(remoteCfgSyncEnabled bool, deployClient DeploymentClient) config.Provider { + if remoteCfgSyncEnabled { + return NewGqlProvider(deployClient) + } -// Merge flattens 2d config bytes -func (y YAMLFiles) Merge() []byte { - return bytes.Join(y, nil) -} + if os.Getenv(EnvProviderConfigPathsEnvKey) != "" { + return NewEnvProvider() + } -// Provider for configuration sources -type Provider interface { - Configs(ctx context.Context) (YAMLFiles, error) + return NewFileSystemProvider(configPathsFlag) } diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go new file mode 100644 index 000000000..8d4eb0666 --- /dev/null +++ b/internal/config/provider_test.go @@ -0,0 +1,69 @@ +package config_test + +import ( + "context" + "os" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kubeshop/botkube/internal/config" +) + +func TestGetProvider(t *testing.T) { + t.Run("from envs variable only", func(t *testing.T) { + // given + t.Setenv("BOTKUBE_CONFIG_PATHS", "testdata/TestGetProvider/first.yaml,testdata/TestGetProvider/second.yaml,testdata/TestGetProvider/third.yaml") + + // when + provider := config.GetProvider(false, nil) + gotConfigs, _, err := provider.Configs(context.Background()) + assert.NoError(t, err) + + // then + c, err := os.ReadFile("testdata/TestGetProvider/all.yaml") + assert.NoError(t, err) + assert.Equal(t, c, gotConfigs.Merge()) + }) + + t.Run("from CLI flag only", func(t *testing.T) { + // given + fSet := pflag.NewFlagSet("testing", pflag.ContinueOnError) + config.RegisterFlags(fSet) + err := fSet.Parse([]string{"--config=testdata/TestGetProvider/first.yaml,testdata/TestGetProvider/second.yaml", "--config", "testdata/TestGetProvider/third.yaml"}) + require.NoError(t, err) + + // when + provider := config.GetProvider(false, nil) + gotConfigs, _, err := provider.Configs(context.Background()) + assert.NoError(t, err) + + // then + c, err := os.ReadFile("testdata/TestGetProvider/all.yaml") + assert.NoError(t, err) + assert.Equal(t, c, gotConfigs.Merge()) + }) + + t.Run("should honor env variable over the CLI flag", func(t *testing.T) { + // given + fSet := pflag.NewFlagSet("testing", pflag.ContinueOnError) + config.RegisterFlags(fSet) + + err := fSet.Parse([]string{"--config=testdata/TestGetProvider/from-cli-flag.yaml,testdata/TestGetProvider/from-cli-flag-second.yaml"}) + require.NoError(t, err) + + t.Setenv("BOTKUBE_CONFIG_PATHS", "testdata/TestGetProvider/first.yaml,testdata/TestGetProvider/second.yaml,testdata/TestGetProvider/third.yaml") + + // when + provider := config.GetProvider(false, nil) + gotConfigs, _, err := provider.Configs(context.Background()) + assert.NoError(t, err) + + // then + c, err := os.ReadFile("testdata/TestGetProvider/all.yaml") + assert.NoError(t, err) + assert.Equal(t, c, gotConfigs.Merge()) + }) +} diff --git a/pkg/config/testdata/TestFromProvider/all.yaml b/internal/config/testdata/TestGetProvider/all.yaml similarity index 100% rename from pkg/config/testdata/TestFromProvider/all.yaml rename to internal/config/testdata/TestGetProvider/all.yaml diff --git a/pkg/config/testdata/TestFromProvider/first.yaml b/internal/config/testdata/TestGetProvider/first.yaml similarity index 100% rename from pkg/config/testdata/TestFromProvider/first.yaml rename to internal/config/testdata/TestGetProvider/first.yaml diff --git a/pkg/config/testdata/TestFromProvider/from-cli-flag-second.yaml b/internal/config/testdata/TestGetProvider/from-cli-flag-second.yaml similarity index 100% rename from pkg/config/testdata/TestFromProvider/from-cli-flag-second.yaml rename to internal/config/testdata/TestGetProvider/from-cli-flag-second.yaml diff --git a/pkg/config/testdata/TestFromProvider/from-cli-flag.yaml b/internal/config/testdata/TestGetProvider/from-cli-flag.yaml similarity index 100% rename from pkg/config/testdata/TestFromProvider/from-cli-flag.yaml rename to internal/config/testdata/TestGetProvider/from-cli-flag.yaml diff --git a/pkg/config/testdata/TestFromProvider/second.yaml b/internal/config/testdata/TestGetProvider/second.yaml similarity index 100% rename from pkg/config/testdata/TestFromProvider/second.yaml rename to internal/config/testdata/TestGetProvider/second.yaml diff --git a/pkg/config/testdata/TestFromProvider/third.yaml b/internal/config/testdata/TestGetProvider/third.yaml similarity index 100% rename from pkg/config/testdata/TestFromProvider/third.yaml rename to internal/config/testdata/TestGetProvider/third.yaml diff --git a/internal/config/testdata/gql_get_deployment_success.json b/internal/config/testdata/gql_get_deployment_success.json index 8025402a6..e3701dba6 100644 --- a/internal/config/testdata/gql_get_deployment_success.json +++ b/internal/config/testdata/gql_get_deployment_success.json @@ -1,7 +1,8 @@ { "data": { "deployment": { - "botkubeConfig": "{\"settings\":{\"clusterName\":\"qa\"},\"sources\":{\"kubernetes-info\":{\"displayName\":\"Kubernetes Information\",\"kubernetes\":{\"recommendations\":{\"pod\":{\"noLatestImageTag\":true,\"labelsSet\":true},\"ingress\":{\"backendServiceValid\":true,\"tlsSecretValid\":true}}}}},\"executors\":{\"kubectl-read-only\":{\"kubectl\":{\"namespaces\":{\"include\":[\".*\"]},\"enabled\":true,\"commands\":{\"verbs\":[\"api-resources\",\"api-versions\",\"cluster-info\",\"describe\",\"diff\",\"explain\",\"get\",\"logs\",\"top\",\"auth\"],\"resources\":[\"deployments\",\"pods\",\"namespaces\",\"daemonsets\",\"statefulsets\",\"storageclasses\",\"nodes\"]},\"defaultNamespace\":\"default\",\"restrictAccess\":false}}},\"communications\":{\"default-group\":{\"socketSlack\":{\"enabled\":true,\"channels\":{\"botkube-demo\":{\"name\":\"botkube-demo\",\"notification\":{\"disabled\":false},\"bindings\":{\"sources\":[\"kubernetes-info\"],\"executors\":[\"kubectl-read-only\"]}}},\"botToken\":\"xoxb-3933899240838-4238355342083-2rUu0vkKDECUj27qECYPCqGd\",\"appToken\":\"xapp-1-A047D1ZJ03B-4262138376928-33d80c4792bb44c189b4f2948e0a00351316b3f694332fdec1ecdc61046320ab\"}}}}" + "resourceVersion": 3, + "yamlConfig": "communications:\n default-group:\n socketSlack:\n appToken: xapp-1-A047D1ZJ03B-4262138376928\n botToken: xoxb-3933899240838\n channels:\n botkube-demo:\n bindings:\n executors:\n - kubectl-read-only\n sources:\n - kubernetes-info\n name: botkube-demo\n notification:\n disabled: false\n enabled: true\nexecutors:\n kubectl-read-only:\n kubectl:\n commands:\n resources:\n - deployments\n - pods\n - namespaces\n - daemonsets\n - statefulsets\n - storageclasses\n - nodes\n verbs:\n - api-resources\n - api-versions\n - cluster-info\n - describe\n - diff\n - explain\n - get\n - logs\n - top\n - auth\n defaultNamespace: default\n enabled: true\n namespaces:\n include:\n - .*\n restrictAccess: false\nsettings:\n clusterName: qa\nsources:\n kubernetes-info:\n displayName: Kubernetes Information\n kubernetes:\n recommendations:\n ingress:\n backendServiceValid: true\n tlsSecretValid: true\n pod:\n labelsSet: true\n noLatestImageTag: true\n" } } } diff --git a/internal/config/updater.go b/internal/config/updater.go new file mode 100644 index 000000000..6e7550ec6 --- /dev/null +++ b/internal/config/updater.go @@ -0,0 +1,113 @@ +package config + +import ( + "context" + "fmt" + "time" + + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + + "github.com/kubeshop/botkube/pkg/config" +) + +// ConfigUpdater is an interface for updating configuration. +type ConfigUpdater interface { + Do(ctx context.Context) error +} + +// ResourceVersionHolder is an interface for holding resource version with ability to set it. +type ResourceVersionHolder interface { + SetResourceVersion(int) +} + +// GetConfigUpdater returns ConfigUpdater based on remoteCfgEnabled flag. +func GetConfigUpdater(remoteCfgEnabled bool, log logrus.FieldLogger, interval time.Duration, deployCli DeploymentClient, resVerHolders ...ResourceVersionHolder) ConfigUpdater { + if remoteCfgEnabled { + return newRemoteConfigUpdater(log, interval, deployCli, resVerHolders...) + } + + return &noopConfigUpdater{} +} + +// newRemoteConfigUpdater returns new ConfigUpdater. +func newRemoteConfigUpdater(log logrus.FieldLogger, interval time.Duration, deployCli DeploymentClient, resVerHolders ...ResourceVersionHolder) ConfigUpdater { + return &GraphQLConfigUpdater{ + log: log, + interval: interval, + deployCli: deployCli, + resVerHolders: resVerHolders, + } +} + +type GraphQLConfigUpdater struct { + log logrus.FieldLogger + interval time.Duration + resVerHolders []ResourceVersionHolder + + latestCfg config.Config + resVersion int + + deployCli DeploymentClient +} + +func (u *GraphQLConfigUpdater) Do(ctx context.Context) error { + u.log.Info("Starting...") + + ticker := time.NewTicker(u.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + u.log.Info("Shutdown requested. Finishing...") + return nil + case <-ticker.C: + u.log.Debug("Querying the latest configuration...") + // Check periodically + cfg, resVer, err := u.queryConfig(ctx) + if err != nil { + wrappedErr := fmt.Errorf("while getting latest config: %w", err) + u.log.Error(wrappedErr.Error()) + continue + } + + if resVer == u.resVersion { + u.log.Debugf("Config version (%d) is the same as the latest one. Skipping...", resVer) + continue + } + + u.latestCfg = cfg + u.setResourceVersionForAll(resVer) + u.log.Debugf("Successfully set newer config version (%d)", resVer) + } + } +} + +func (u *GraphQLConfigUpdater) queryConfig(ctx context.Context) (config.Config, int, error) { + deploy, err := u.deployCli.GetConfigWithResourceVersion(ctx) + if err != nil { + return config.Config{}, 0, fmt.Errorf("while getting deployment: %w", err) + } + + var latestCfg config.Config + err = yaml.Unmarshal([]byte(deploy.YAMLConfig), &latestCfg) + if err != nil { + return config.Config{}, 0, fmt.Errorf("while unmarshaling config: %w", err) + } + + return latestCfg, deploy.ResourceVersion, nil +} + +func (u *GraphQLConfigUpdater) setResourceVersionForAll(resVersion int) { + u.resVersion = resVersion + for _, h := range u.resVerHolders { + h.SetResourceVersion(u.resVersion) + } +} + +type noopConfigUpdater struct{} + +func (u *noopConfigUpdater) Do(ctx context.Context) error { + return nil +} diff --git a/internal/executor/kubectl/config.go b/internal/executor/kubectl/config.go index 5ed5446b3..c2ce85bbf 100644 --- a/internal/executor/kubectl/config.go +++ b/internal/executor/kubectl/config.go @@ -7,15 +7,15 @@ import ( "k8s.io/utils/strings/slices" "github.com/kubeshop/botkube/internal/executor/kubectl/builder" - "github.com/kubeshop/botkube/internal/loggerx" "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/api/executor" + "github.com/kubeshop/botkube/pkg/config" "github.com/kubeshop/botkube/pkg/pluginx" ) // Config holds Kubectl plugin configuration parameters. type Config struct { - Log loggerx.Config `yaml:"log"` + Log config.Logger `yaml:"log"` DefaultNamespace string `yaml:"defaultNamespace,omitempty"` InteractiveBuilder builder.Config `yaml:"interactiveBuilder,omitempty"` } diff --git a/internal/graphql/client.go b/internal/graphql/client.go index 14b4fc9ff..c6dada87b 100644 --- a/internal/graphql/client.go +++ b/internal/graphql/client.go @@ -19,6 +19,10 @@ const ( GqlProviderAPIKeyEnvKey = "CONFIG_PROVIDER_API_KEY" ) +func IsRemoteConfigEnabled() bool { + return os.Getenv(GqlProviderIdentifierEnvKey) != "" +} + // Option define GraphQL client option. type Option func(*Gql) diff --git a/internal/loggerx/logger.go b/internal/loggerx/logger.go index 04c39bd46..474d5b1ee 100644 --- a/internal/loggerx/logger.go +++ b/internal/loggerx/logger.go @@ -4,16 +4,12 @@ import ( "os" "github.com/sirupsen/logrus" -) -// Config holds logger configuration parameters. -type Config struct { - Level string `yaml:"level"` - DisableColors bool `yaml:"disableColors"` -} + "github.com/kubeshop/botkube/pkg/config" +) // New returns a new logger based on a given configuration. -func New(cfg Config) logrus.FieldLogger { +func New(cfg config.Logger) logrus.FieldLogger { logger := logrus.New() // Output to stdout instead of the default stderr logger.SetOutput(os.Stdout) diff --git a/internal/plugin/logger.go b/internal/plugin/logger.go index 8d45f79cd..b6b345e56 100644 --- a/internal/plugin/logger.go +++ b/internal/plugin/logger.go @@ -11,6 +11,7 @@ import ( "github.com/sirupsen/logrus" "github.com/kubeshop/botkube/internal/loggerx" + "github.com/kubeshop/botkube/pkg/config" ) var specialCharsPattern = regexp.MustCompile(`(?i:[^A-Z0-9_])`) @@ -25,7 +26,7 @@ var specialCharsPattern = regexp.MustCompile(`(?i:[^A-Z0-9_])`) func NewPluginLoggers(bkLogger logrus.FieldLogger, pluginKey string, pluginType Type) (hclog.Logger, io.Writer, io.Writer) { pluginLogLevel := getPluginLogLevel(bkLogger, pluginKey, pluginType) - cfg := loggerx.Config{ + cfg := config.Logger{ Level: pluginLogLevel.String(), } log := loggerx.New(cfg).WithField("plugin", pluginKey) diff --git a/internal/source/kubernetes/source.go b/internal/source/kubernetes/source.go index 43d68686f..4b2345d2b 100644 --- a/internal/source/kubernetes/source.go +++ b/internal/source/kubernetes/source.go @@ -24,6 +24,7 @@ import ( "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/api/source" "github.com/kubeshop/botkube/pkg/bot/interactive" + pkgConfig "github.com/kubeshop/botkube/pkg/config" ) const ( @@ -79,7 +80,7 @@ func (*Source) Stream(ctx context.Context, input source.StreamInput) (source.Str startTime: time.Now(), eventCh: make(chan source.Event), config: cfg, - logger: loggerx.New(loggerx.Config{ + logger: loggerx.New(pkgConfig.Logger{ Level: cfg.Log.Level, }), clusterName: input.Context.ClusterName, diff --git a/internal/source/prometheus/source.go b/internal/source/prometheus/source.go index 87e4e5064..587e914af 100644 --- a/internal/source/prometheus/source.go +++ b/internal/source/prometheus/source.go @@ -11,6 +11,7 @@ import ( "github.com/kubeshop/botkube/internal/loggerx" "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/api/source" + "github.com/kubeshop/botkube/pkg/config" formatx "github.com/kubeshop/botkube/pkg/format" ) @@ -57,18 +58,18 @@ func (p *Source) Metadata(_ context.Context) (api.MetadataOutput, error) { }, nil } -func (p *Source) consumeAlerts(ctx context.Context, config Config, ch chan<- []byte) { - log := loggerx.New(loggerx.Config{ - Level: config.Log.Level, +func (p *Source) consumeAlerts(ctx context.Context, cfg Config, ch chan<- []byte) { + log := loggerx.New(config.Logger{ + Level: cfg.Log.Level, }) - prometheus, err := NewClient(config.URL) + prometheus, err := NewClient(cfg.URL) exitOnError(err, log) for { alerts, err := prometheus.Alerts(ctx, GetAlertsRequest{ - IgnoreOldAlerts: *config.IgnoreOldAlerts, + IgnoreOldAlerts: *cfg.IgnoreOldAlerts, MinAlertTime: p.startedAt, - AlertStates: config.AlertStates, + AlertStates: cfg.AlertStates, }) if err != nil { log.Errorf("failed to get alerts. %v", err) diff --git a/internal/source/scheduler_test.go b/internal/source/scheduler_test.go index 529865b97..e24db7e24 100644 --- a/internal/source/scheduler_test.go +++ b/internal/source/scheduler_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" - intConfig "github.com/kubeshop/botkube/internal/config" "github.com/kubeshop/botkube/internal/loggerx" "github.com/kubeshop/botkube/pkg/api/source" "github.com/kubeshop/botkube/pkg/config" @@ -19,7 +18,7 @@ import ( func TestStartingUniqueProcesses(t *testing.T) { // given - files := intConfig.YAMLFiles{ + files := config.YAMLFiles{ readTestdataFile(t, "config.yaml"), } givenCfg, _, err := config.LoadWithDefaults(files) diff --git a/internal/status/gql_reporter.go b/internal/status/gql_reporter.go index fe0011051..cfefdc9d0 100644 --- a/internal/status/gql_reporter.go +++ b/internal/status/gql_reporter.go @@ -2,8 +2,12 @@ package status import ( "context" + "sync" + "time" + "github.com/avast/retry-go" "github.com/hasura/go-graphql-client" + "github.com/pkg/errors" "github.com/sirupsen/logrus" gql "github.com/kubeshop/botkube/internal/graphql" @@ -11,56 +15,143 @@ import ( var _ StatusReporter = (*GraphQLStatusReporter)(nil) +// ResVerClient defines client for getting resource version. +type ResVerClient interface { + GetResourceVersion(ctx context.Context) (int, error) +} + type GraphQLStatusReporter struct { - log logrus.FieldLogger - gql *gql.Gql + log logrus.FieldLogger + gql *gql.Gql + resVerClient ResVerClient + resourceVersion int + resVerMutex sync.RWMutex } -func newGraphQLStatusReporter(logger logrus.FieldLogger, client *gql.Gql) *GraphQLStatusReporter { +func newGraphQLStatusReporter(logger logrus.FieldLogger, client *gql.Gql, resVerClient ResVerClient, cfgVersion int) *GraphQLStatusReporter { return &GraphQLStatusReporter{ - log: logger, - gql: client, + log: logger, + gql: client, + resVerClient: resVerClient, + resourceVersion: cfgVersion, } } -func (r *GraphQLStatusReporter) ReportDeploymentStartup(ctx context.Context) (bool, error) { - r.log.Debugf("Reporting deployment startup for ID: %s", r.gql.DeploymentID) - var mutation struct { - Success bool `graphql:"reportDeploymentStartup(id: $id)"` - } - variables := map[string]interface{}{ - "id": graphql.ID(r.gql.DeploymentID), - } - if err := r.gql.Cli.Mutate(ctx, &mutation, variables); err != nil { - return false, err +func (r *GraphQLStatusReporter) ReportDeploymentStartup(ctx context.Context) error { + logger := r.log.WithFields(logrus.Fields{ + "deploymentID": r.gql.DeploymentID, + "resourceVersion": r.getResourceVersion(), + "type": "startup", + }) + logger.Debug("Reporting...") + + err := r.withRetry(ctx, logger, func() error { + var mutation struct { + Success bool `graphql:"reportDeploymentStartup(id: $id, resourceVersion: $resourceVersion)"` + } + variables := map[string]interface{}{ + "id": graphql.ID(r.gql.DeploymentID), + "resourceVersion": r.getResourceVersion(), + } + err := r.gql.Cli.Mutate(ctx, &mutation, variables) + return err + }) + if err != nil { + return errors.Wrap(err, "while reporting deployment startup") } - return mutation.Success, nil + + logger.Debug("Reporting successful.") + return nil } -func (r *GraphQLStatusReporter) ReportDeploymentShutdown(ctx context.Context) (bool, error) { - r.log.Debugf("Reporting deployment shutdown for ID: %s", r.gql.DeploymentID) - var mutation struct { - Success bool `graphql:"reportDeploymentShutdown(id: $id)"` - } - variables := map[string]interface{}{ - "id": graphql.ID(r.gql.DeploymentID), - } - if err := r.gql.Cli.Mutate(ctx, &mutation, variables); err != nil { - return false, err +func (r *GraphQLStatusReporter) ReportDeploymentShutdown(ctx context.Context) error { + logger := r.log.WithFields(logrus.Fields{ + "deploymentID": r.gql.DeploymentID, + "resourceVersion": r.getResourceVersion(), + "type": "shutdown", + }) + logger.Debug("Reporting...") + + err := r.withRetry(ctx, logger, func() error { + var mutation struct { + Success bool `graphql:"reportDeploymentShutdown(id: $id, resourceVersion: $resourceVersion)"` + } + variables := map[string]interface{}{ + "id": graphql.ID(r.gql.DeploymentID), + "resourceVersion": r.getResourceVersion(), + } + return r.gql.Cli.Mutate(ctx, &mutation, variables) + }) + if err != nil { + return errors.Wrap(err, "while reporting deployment shutdown") } - return mutation.Success, nil + + logger.Debug("Reporting successful.") + return nil } -func (r *GraphQLStatusReporter) ReportDeploymentFailed(ctx context.Context) (bool, error) { - r.log.Debugf("Reporting deployment failure for ID: %s", r.gql.DeploymentID) - var mutation struct { - Success bool `graphql:"reportDeploymentFailed(id: $id)"` - } - variables := map[string]interface{}{ - "id": graphql.ID(r.gql.DeploymentID), +func (r *GraphQLStatusReporter) ReportDeploymentFailed(ctx context.Context) error { + logger := r.log.WithFields(logrus.Fields{ + "deploymentID": r.gql.DeploymentID, + "resourceVersion": r.getResourceVersion(), + "type": "failure", + }) + logger.Debug("Reporting...") + + err := r.withRetry(ctx, logger, func() error { + var mutation struct { + Success bool `graphql:"reportDeploymentFailed(id: $id, resourceVersion: $resourceVersion)"` + } + variables := map[string]interface{}{ + "id": graphql.ID(r.gql.DeploymentID), + "resourceVersion": r.getResourceVersion(), + } + return r.gql.Cli.Mutate(ctx, &mutation, variables) + }) + if err != nil { + return errors.Wrap(err, "while reporting deployment shutdown") } - if err := r.gql.Cli.Mutate(ctx, &mutation, variables); err != nil { - return false, err + + logger.Debug("Reporting successful.") + return nil +} + +func (r *GraphQLStatusReporter) SetResourceVersion(resourceVersion int) { + r.resVerMutex.Lock() + defer r.resVerMutex.Unlock() + r.resourceVersion = resourceVersion +} + +const ( + retries = 3 + delay = 200 * time.Millisecond +) + +func (r *GraphQLStatusReporter) withRetry(ctx context.Context, logger logrus.FieldLogger, fn func() error) error { + err := retry.Do( + fn, + retry.OnRetry(func(n uint, err error) { + logger.Debugf("Retrying (attempt no %d/%d): %s.\nFetching latest resource version...", n+1, retries, err) + resVer, err := r.resVerClient.GetResourceVersion(ctx) + if err != nil { + logger.Errorf("Error while fetching resource version: %s", err) + } + r.SetResourceVersion(resVer) + }), + retry.Delay(delay), + retry.Attempts(retries), + retry.LastErrorOnly(true), + retry.Context(ctx), + ) + if err != nil { + return errors.Wrap(err, "while retrying") } - return mutation.Success, nil + + return nil +} + +func (r *GraphQLStatusReporter) getResourceVersion() int { + r.resVerMutex.RLock() + defer r.resVerMutex.RUnlock() + return r.resourceVersion } diff --git a/internal/status/noop_reporter.go b/internal/status/noop_reporter.go index 425853025..bbacd9faf 100644 --- a/internal/status/noop_reporter.go +++ b/internal/status/noop_reporter.go @@ -2,32 +2,25 @@ package status import ( "context" - - "github.com/sirupsen/logrus" ) var _ StatusReporter = (*NoopStatusReporter)(nil) -type NoopStatusReporter struct { - log logrus.FieldLogger -} +type NoopStatusReporter struct{} -func newNoopStatusReporter(logger logrus.FieldLogger) *NoopStatusReporter { - return &NoopStatusReporter{ - log: logger, - } +func newNoopStatusReporter() *NoopStatusReporter { + return &NoopStatusReporter{} } -func (r *NoopStatusReporter) ReportDeploymentStartup(ctx context.Context) (bool, error) { - r.log.Debug("ReportDeploymentStartup") - return true, nil +func (r *NoopStatusReporter) ReportDeploymentStartup(context.Context) error { + return nil } -func (r *NoopStatusReporter) ReportDeploymentShutdown(ctx context.Context) (bool, error) { - r.log.Debug("ReportDeploymentShutdown") - return true, nil +func (r *NoopStatusReporter) ReportDeploymentShutdown(context.Context) error { + return nil } -func (r *NoopStatusReporter) ReportDeploymentFailed(ctx context.Context) (bool, error) { - r.log.Debug("ReportDeploymentFailed") - return true, nil +func (r *NoopStatusReporter) ReportDeploymentFailed(context.Context) error { + return nil } + +func (r *NoopStatusReporter) SetResourceVersion(int) {} diff --git a/internal/status/reporter.go b/internal/status/reporter.go index 67321bcc6..fed3a70da 100644 --- a/internal/status/reporter.go +++ b/internal/status/reporter.go @@ -2,7 +2,6 @@ package status import ( "context" - "os" "github.com/sirupsen/logrus" @@ -10,14 +9,21 @@ import ( ) type StatusReporter interface { - ReportDeploymentStartup(ctx context.Context) (bool, error) - ReportDeploymentShutdown(ctx context.Context) (bool, error) - ReportDeploymentFailed(ctx context.Context) (bool, error) + ReportDeploymentStartup(ctx context.Context) error + ReportDeploymentShutdown(ctx context.Context) error + ReportDeploymentFailed(ctx context.Context) error + SetResourceVersion(resourceVersion int) } -func NewStatusReporter(logger logrus.FieldLogger, gql *graphql.Gql) StatusReporter { - if _, provided := os.LookupEnv(graphql.GqlProviderIdentifierEnvKey); provided { - return newGraphQLStatusReporter(logger.WithField("component", "GraphQLStatusReporter"), gql) +func NewStatusReporter(remoteCfgEnabled bool, logger logrus.FieldLogger, gql *graphql.Gql, resVerClient ResVerClient, cfgVersion int) StatusReporter { + if remoteCfgEnabled { + return newGraphQLStatusReporter( + logger.WithField("component", "GraphQLStatusReporter"), + gql, + resVerClient, + cfgVersion, + ) } - return newNoopStatusReporter(logger.WithField("component", "NoopStatusReporter")) + + return newNoopStatusReporter() } diff --git a/pkg/config/config.go b/pkg/config/config.go index 3a155082e..e232ec76f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -3,7 +3,6 @@ package config import ( _ "embed" "fmt" - "os" "regexp" "strings" "time" @@ -12,21 +11,13 @@ import ( koanfyaml "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/rawbytes" - "github.com/spf13/pflag" "golang.org/x/text/cases" "golang.org/x/text/language" - - "github.com/kubeshop/botkube/internal/config" - intconfig "github.com/kubeshop/botkube/internal/config" - "github.com/kubeshop/botkube/internal/graphql" - "github.com/kubeshop/botkube/internal/loggerx" ) //go:embed default.yaml var defaultConfiguration []byte -var configPathsFlag []string - const ( configEnvVariablePrefix = "BOTKUBE_" configDelimiter = "." @@ -218,7 +209,7 @@ type ActionBindings struct { type Sources struct { DisplayName string `yaml:"displayName"` Kubernetes KubernetesSource `yaml:"kubernetes"` - Plugins Plugins `koanf:",remain"` + Plugins Plugins `yaml:",inline" koanf:",remain"` } // GetPlugins returns Sources.Plugins. @@ -380,7 +371,7 @@ const ( // Executors contains executors configuration parameters. type Executors struct { Kubectl Kubectl `yaml:"kubectl"` - Plugins Plugins `koanf:",remain"` + Plugins Plugins `yaml:",inline" koanf:",remain"` } // CollectCommandPrefixes returns list of command prefixes for all executors, even disabled ones. @@ -691,11 +682,17 @@ type Settings struct { MetricsPort string `yaml:"metricsPort"` HealthPort string `yaml:"healthPort"` LifecycleServer LifecycleServer `yaml:"lifecycleServer"` - Log loggerx.Config `yaml:"log"` + Log Logger `yaml:"log"` InformersResyncPeriod time.Duration `yaml:"informersResyncPeriod"` Kubeconfig string `yaml:"kubeconfig"` } +// Logger holds logger configuration parameters. +type Logger struct { + Level string `yaml:"level"` + DisableColors bool `yaml:"disableColors"` +} + // LifecycleServer contains configuration for the server with app lifecycle methods. type LifecycleServer struct { Enabled bool `yaml:"enabled"` @@ -775,26 +772,6 @@ func LoadWithDefaults(configs [][]byte) (*Config, LoadWithDefaultsDetails, error }, nil } -// GetProvider resolves and returns paths for config files. -// It reads them the 'BOTKUBE_CONFIG_PATHS' env variable. If not found, then it uses '--config' flag. -func GetProvider(gql *graphql.Gql) config.Provider { - if _, provided := os.LookupEnv(graphql.GqlProviderIdentifierEnvKey); provided { - dc := intconfig.NewDeploymentClient(gql) - return config.NewGqlProvider(dc) - } - - if os.Getenv(intconfig.EnvProviderConfigPathsEnvKey) != "" { - return config.NewEnvProvider() - } - - return config.NewFileSystemProvider(configPathsFlag) -} - -// RegisterFlags registers config related flags. -func RegisterFlags(flags *pflag.FlagSet) { - flags.StringSliceVarP(&configPathsFlag, "config", "c", nil, "Specify configuration file in YAML format (can specify multiple).") -} - func normalizeConfigEnvName(name string) string { name = strings.TrimPrefix(name, configEnvVariablePrefix) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 5417032c2..1b6c7bc23 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,19 +1,16 @@ package config_test import ( - "context" "os" "path/filepath" "testing" "github.com/MakeNowJust/heredoc" - "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "gotest.tools/v3/golden" - intConfig "github.com/kubeshop/botkube/internal/config" "github.com/kubeshop/botkube/pkg/config" ) @@ -31,7 +28,7 @@ func TestLoadConfigSuccess(t *testing.T) { t.Setenv("BOTKUBE_PLUGINS_REPOSITORIES_BOTKUBE_URL", "http://localhost:3000/botkube.yaml") // when - files := intConfig.YAMLFiles{ + files := config.YAMLFiles{ readTestdataFile(t, "config-all.yaml"), readTestdataFile(t, "config-global.yaml"), readTestdataFile(t, "config-slack-override.yaml"), @@ -72,7 +69,7 @@ func TestLoadConfigWithPlugins(t *testing.T) { }, } - files := intConfig.YAMLFiles{ + files := config.YAMLFiles{ readTestdataFile(t, "config-all.yaml"), } @@ -87,62 +84,6 @@ func TestLoadConfigWithPlugins(t *testing.T) { assert.Equal(t, expExecutorPlugin, gotCfg.Executors["plugin-based"].Plugins) } -func TestFromProvider(t *testing.T) { - t.Run("from envs variable only", func(t *testing.T) { - // given - t.Setenv("BOTKUBE_CONFIG_PATHS", "testdata/TestFromProvider/first.yaml,testdata/TestFromProvider/second.yaml,testdata/TestFromProvider/third.yaml") - - // when - provider := config.GetProvider(nil) - gotConfigs, err := provider.Configs(context.Background()) - assert.NoError(t, err) - - // then - c, err := os.ReadFile("testdata/TestFromProvider/all.yaml") - assert.NoError(t, err) - assert.Equal(t, c, gotConfigs.Merge()) - }) - - t.Run("from CLI flag only", func(t *testing.T) { - // given - fSet := pflag.NewFlagSet("testing", pflag.ContinueOnError) - config.RegisterFlags(fSet) - err := fSet.Parse([]string{"--config=testdata/TestFromProvider/first.yaml,testdata/TestFromProvider/second.yaml", "--config", "testdata/TestFromProvider/third.yaml"}) - require.NoError(t, err) - - // when - provider := config.GetProvider(nil) - gotConfigs, err := provider.Configs(context.Background()) - assert.NoError(t, err) - - // then - c, err := os.ReadFile("testdata/TestFromProvider/all.yaml") - assert.NoError(t, err) - assert.Equal(t, c, gotConfigs.Merge()) - }) - - t.Run("should honor env variable over the CLI flag", func(t *testing.T) { - // given - fSet := pflag.NewFlagSet("testing", pflag.ContinueOnError) - config.RegisterFlags(fSet) - - err := fSet.Parse([]string{"--config=testdata/TestFromProvider/from-cli-flag.yaml,testdata/TestFromProvider/from-cli-flag-second.yaml"}) - require.NoError(t, err) - - t.Setenv("BOTKUBE_CONFIG_PATHS", "testdata/TestFromProvider/first.yaml,testdata/TestFromProvider/second.yaml,testdata/TestFromProvider/third.yaml") - - // when - provider := config.GetProvider(nil) - gotConfigs, err := provider.Configs(context.Background()) - assert.NoError(t, err) - - // then - c, err := os.ReadFile("testdata/TestFromProvider/all.yaml") - assert.NoError(t, err) - assert.Equal(t, c, gotConfigs.Merge()) - }) -} - func TestNormalizeConfigEnvName(t *testing.T) { // given tests := []struct { diff --git a/pkg/config/provider.go b/pkg/config/provider.go new file mode 100644 index 000000000..3e6c6d4ad --- /dev/null +++ b/pkg/config/provider.go @@ -0,0 +1,19 @@ +package config + +import ( + "bytes" + "context" +) + +// YAMLFiles denotes list of configurations in bytes +type YAMLFiles [][]byte + +// Merge flattens 2d config bytes +func (y YAMLFiles) Merge() []byte { + return bytes.Join(y, nil) +} + +// Provider for configuration sources +type Provider interface { + Configs(ctx context.Context) (YAMLFiles, int, error) +} diff --git a/pkg/config/testdata/TestLoadConfigSuccess/config.golden.yaml b/pkg/config/testdata/TestLoadConfigSuccess/config.golden.yaml index 33043a567..3b01bbac5 100644 --- a/pkg/config/testdata/TestLoadConfigSuccess/config.golden.yaml +++ b/pkg/config/testdata/TestLoadConfigSuccess/config.golden.yaml @@ -326,23 +326,22 @@ sources: my-annotation: "true" labels: my-label: "true" - plugins: - botkube/keptn: - enabled: true - config: - field: value - context: - rbac: - user: - type: "" - static: - value: "" - prefix: "" - group: - type: "" - static: - values: [] - prefix: "" + botkube/keptn: + enabled: true + config: + field: value + context: + rbac: + user: + type: "" + static: + value: "" + prefix: "" + group: + type: "" + static: + values: [] + prefix: "" executors: kubectl-read-only: kubectl: @@ -376,27 +375,25 @@ executors: - nodes defaultNamespace: default restrictAccess: false - plugins: {} plugin-based: kubectl: enabled: false - plugins: - botkube/echo: - enabled: true - config: - changeResponseToUpperCase: true - context: - rbac: - user: - type: "" - static: - value: "" - prefix: "" - group: - type: "" - static: - values: [] - prefix: "" + botkube/echo: + enabled: true + config: + changeResponseToUpperCase: true + context: + rbac: + user: + type: "" + static: + value: "" + prefix: "" + group: + type: "" + static: + values: [] + prefix: "" aliases: {} communications: default-workspace: diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 620f34c34..d60f2d0b6 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -69,7 +69,7 @@ func (c *Controller) Start(ctx context.Context) error { // use separate ctx as parent ctx is already cancelled ctxTimeout, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() - if _, err := c.statusReporter.ReportDeploymentShutdown(ctxTimeout); err != nil { + if err := c.statusReporter.ReportDeploymentShutdown(ctxTimeout); err != nil { return fmt.Errorf("while reporting botkube shutdown: %w", err) } diff --git a/pkg/controller/upgrade.go b/pkg/controller/upgrade.go index 501423098..71de81d68 100644 --- a/pkg/controller/upgrade.go +++ b/pkg/controller/upgrade.go @@ -80,11 +80,12 @@ func (c *UpgradeChecker) notifyAboutUpgradeIfShould(ctx context.Context) (bool, return false, fmt.Errorf("while getting latest release from GitHub: %w", err) } - c.log.Debugf("latest release info: %+v", release) - if release.TagName == nil { + if release == nil || release.TagName == nil { return false, errors.New("release tag is empty") } + c.log.Debugf("latest release tag: %s", *release.TagName) + // Send notification if newer version available if version.Short() == *release.TagName { // no new version, finish