diff --git a/.gitignore b/.gitignore index 0c51a726..976d494f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ *.orig *.swp .idea + +/resources diff --git a/.golangci.toml b/.golangci.toml new file mode 100644 index 00000000..c2976a86 --- /dev/null +++ b/.golangci.toml @@ -0,0 +1,24 @@ +[linters] +disable-all = true +enable = [ + "dogsled", + "errcheck", + "exportloopref", + "goconst", + "gocritic", + "gocyclo", + "goimports", + "goprintffuncname", + "gosimple", + "govet", + "ineffassign", + "misspell", + "nakedret", + "rowserrcheck", + "staticcheck", + "stylecheck", + "typecheck", + "unconvert", + "unused", + "whitespace" +] diff --git a/Makefile b/Makefile index f2776c9d..9c1be1bd 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,11 @@ GOX := $(BIN_DIR)/gox DOCKER_COMPOSE := docker compose -f ./test-docker-compose/docker-compose.yml lint: - test -z $$(gofmt -s -l cmd/ pkg/) - go vet ./... + docker run \ + --rm \ + --volume "$(shell pwd):/src" \ + --workdir "/src" \ + golangci/golangci-lint:v1.57 golangci-lint run ./... -v run-test-image-locally: test-clean $(DOCKER_COMPOSE) up --force-recreate --detach --remove-orphans --wait diff --git a/cmd/grr/workflow.go b/cmd/grr/workflow.go index cbbbe524..3e520583 100644 --- a/cmd/grr/workflow.go +++ b/cmd/grr/workflow.go @@ -12,7 +12,7 @@ import ( "github.com/grafana/grizzly/pkg/grizzly/notifier" "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - "golang.org/x/crypto/ssh/terminal" + terminal "golang.org/x/term" ) const generalFolderUID = "general" @@ -508,7 +508,9 @@ func configCmd() *cli.Command { func initialiseCmd(cmd *cli.Command, opts *Opts) *cli.Command { // Keep the old flags for backwards compatibility cmd.Flags().BoolVarP(&opts.Directory, "directory", "d", false, "treat resource path as a directory") - cmd.Flags().MarkDeprecated("directory", "now it is inferred from the operating system") + if err := cmd.Flags().MarkDeprecated("directory", "now it is inferred from the operating system"); err != nil { + log.Fatal(err) + } cmd.Flags().StringSliceVarP(&opts.Targets, "target", "t", nil, "resources to target") cmd.Flags().StringSliceVarP(&opts.JsonnetPaths, "jpath", "J", getDefaultJsonnetFolders(), "Specify an additional library search dir (right-most wins)") diff --git a/docs/content/configuration.md b/docs/content/configuration.md index 5e31b6c6..dd967c02 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -32,7 +32,7 @@ establish authentication credentials. ```sh grr config set grafana.url http://localhost:3000 # URL for the root of your Grafana instance -grr config set grafana.user admin # Optional: Username if using basic auth +grr config set grafana.user admin # (Optional) Username if using basic auth grr config set grafana.token abcd12345 # Service account token (or basic auth password) ``` @@ -40,23 +40,26 @@ grr config set grafana.token abcd12345 # Service account token (or basic auth pa To interact with Grafana Cloud Prometheus (aka Mimir), use these settings: ```sh -grr config set mimir.address https://mimir.example.com # URL for Grafana Cloud Prometheus instance -grr config set mimir.tenant-id 1234567 # Tenant ID for your Grafana Cloud Prometheus account -grr config set mimir.api-key abcdef12345 # Authentication token +grr config set mimir.address https://mimir.example.com # URL for Mimir instance or Grafana Cloud Prometheus instance +grr config set mimir.tenant-id myTenant # Tenant ID for your Grafana Cloud Prometheus account +grr config set mimir.api-key abcdef12345 # Authentication token (if you are using Grafana Cloud) ``` -Note, this will also work with other Cortex installations, alongside Grafana Cloud Prometheus/Mimir. +**Notes** +* Be sure to set `api-key` when you need to interact with Grafana Cloud. ## Grafana Synthetic Monitoring To interact with Grafana Synthetic Monitoring, you must configure the below settings: ```sh grr config set synthetic-monitoring.token abcdef123456 # API key (must have MetricsPublisher permissions) -grr config set synthetic-monitoring.stack-id # Grafana stack ID -grr config set synthetic-monitoring.metrics-id # Metrics instance ID -grr config set synthetic-monitoring.logs-id # Logs instance ID +grr config set synthetic-monitoring.stack-id 123 # Grafana stack ID +grr config set synthetic-monitoring.metrics-id 123 # Metrics instance ID +grr config set synthetic-monitoring.logs-id 123 # Logs instance ID +grr config set synthetic-monitoring.url https://synthetic-monitoring-api.grafana.net # Synthetic Monitoring instance URL ``` Your stack ID is the number at the end of the url when you view your Grafana instance details, ie. `grafana.com/orgs/myorg/stacks/123456` would be `123456`. Your metrics and logs ID's are the `User` when you view your Prometheus or Loki instance details in Grafana Cloud. +You can find your instance URL under your Synthetic Monitoring configuration. ## Configuring Targets Grizzly supports a number of resource types (`grr providers` will list those supported). Often, however, we do not @@ -135,11 +138,11 @@ In some circumstances (e.g. when used within automated pipelines) it makes sense with environment variables as opposed to contexts. Environment variables, when set, take precedence over Grizzly contexts as described above. Below are the variables that can be used for this. -| Name | Description | Required | Default | -| --- | --- | --- | --- | -| `GRAFANA_URL` | Fully qualified domain name of your Grafana instance. | true | - | -| `GRAFANA_USER` | Basic auth username if applicable. | false | `api_key` | -| `GRAFANA_TOKEN` | Basic auth password or API token. | false | - | +| Name | Description | Required | Default | +|-----------------|-------------------------------------------------------|----------|-----------| +| `GRAFANA_URL` | Fully qualified domain name of your Grafana instance. | true | - | +| `GRAFANA_USER` | Basic auth username if applicable. | false | `api_key` | +| `GRAFANA_TOKEN` | Basic auth password or API token. | false | - | See Grafana's [Authentication API docs](https://grafana.com/docs/grafana/latest/http_api/auth/) for more info. @@ -147,25 +150,27 @@ docs](https://grafana.com/docs/grafana/latest/http_api/auth/) for more info. ## Grafana Cloud Prometheus To interact with Grafana Cloud Prometheus, you must have these environment variables set: -| Name | Description | Required | -| --- | --- | --- | -| `CORTEX_ADDRESS` | URL for Grafana Cloud Prometheus instance | true | -| `CORTEX_TENANT_ID` | Tenant ID for your Grafana Cloud Prometheus account | true | -| `CORTEX_API_KEY` | Authentication token/api key | true | +| Name | Description | Required | +|-------------------|-----------------------------------------------------|----------| +| `MIMIR_ADDRESS` | URL for Grafana Cloud Prometheus instance | true | +| `MIMIR_TENANT_ID` | Tenant ID for your Grafana Cloud Prometheus account | true | +| `MIMIR_API_KEY` | Authentication token/api key | false | -Note, this will also work with other Cortex installations, alongside Grafana Cloud Prometheus. +Note, this will also work with other Mimir installations, alongside Grafana Cloud Prometheus. ## Grafana Synthetic Monitoring To interact with Grafana Synthetic Monitoring, you must have these environment variable set: -| Name | Description | Required | -| --- | --- | --- | -| `GRAFANA_SM_TOKEN` | Authentication token/api key (must have MetricsPublisher permissions) | true | -| `GRAFANA_SM_STACK_ID` | Grafana instance/stack ID | true | -| `GRAFANA_SM_LOGS_ID` | Logs instance ID | true | -| `GRAFANA_SM_METRICS_ID` | Metrics instance ID | true | +| Name | Description | Required | +|-------------------------|-----------------------------------------------------------------------|----------| +| `GRAFANA_SM_TOKEN` | Authentication token/api key (must have MetricsPublisher permissions) | true | +| `GRAFANA_SM_STACK_ID` | Grafana instance/stack ID | true | +| `GRAFANA_SM_LOGS_ID` | Logs instance ID | true | +| `GRAFANA_SM_METRICS_ID` | Metrics instance ID | true | +| `GRAFANA_SM_URL` | Synthetic Monitoring instance URL | true | Your stack ID is the number at the end of the url when you view your Grafana instance details, ie. `grafana.com/orgs/myorg/stacks/123456` would be `123456`. Your metrics and logs ID's are the `User` when you view your Prometheus or Loki instance details in Grafana Cloud. +You can find your instance URL under your Synthetic Monitoring configuration. # Grizzly configuration file To get the path of the config file: diff --git a/docs/content/prometheus.md b/docs/content/prometheus.md index d6db4a23..b5393e16 100644 --- a/docs/content/prometheus.md +++ b/docs/content/prometheus.md @@ -9,7 +9,7 @@ local disk. As Grizzly focuses on systems that can be managed via HTTP APIs, Grizzly cannot (currently) work with Prometheus itself. Various hosted Prometheus installations, such as Grafana Cloud Prometheus -are supported, as are systems running Cortex. +are supported, as are systems running Mimir. ## Configuring Prometheus Prometheus alert and recording rules are both created using the same `kind`: diff --git a/go.mod b/go.mod index 43f46c9a..55449cc0 100644 --- a/go.mod +++ b/go.mod @@ -80,7 +80,7 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect - golang.org/x/net v0.22.0 // indirect + golang.org/x/net v0.23.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.18.0 // indirect golang.org/x/term v0.18.0 // indirect diff --git a/go.sum b/go.sum index b25098ee..ab7520ef 100644 --- a/go.sum +++ b/go.sum @@ -195,8 +195,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/integration/folder_test.go b/integration/folder_test.go index ddaaeb7f..4373b3bc 100644 --- a/integration/folder_test.go +++ b/integration/folder_test.go @@ -83,7 +83,7 @@ func TestFolders(t *testing.T) { t.Run("get remote folder - not found", func(t *testing.T) { _, err := handler.GetByUID("dummy") - require.ErrorContains(t, err, "Couldn't fetch folder 'dummy' from remote: not found") + require.ErrorContains(t, err, "couldn't fetch folder 'dummy' from remote: not found") }) t.Run("get folders list", func(t *testing.T) { diff --git a/integration/pull_test.go b/integration/pull_test.go index 19d6b126..18fcd79d 100644 --- a/integration/pull_test.go +++ b/integration/pull_test.go @@ -29,7 +29,6 @@ func TestPull(t *testing.T) { assert.FileExists(t, filepath.Join(pullDir, "dashboards", "abcdefghi", "dashboard-ReciqtgGk.yaml")) assert.DirExists(t, filepath.Join(pullDir, "datasources")) assert.DirExists(t, filepath.Join(pullDir, "folders")) - }, }) }) diff --git a/pkg/config/config.go b/pkg/config/config.go index 75a5a135..02ba41cf 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,8 +16,7 @@ import ( ) const ( - API_VERSION = "v1alpha1" - CURRENT_CONTEXT = "current-context" + CurrentContextSetting = "current-context" ) func Initialise() { @@ -38,19 +37,39 @@ func override(v *viper.Viper) { "synthetic-monitoring.stack-id": "GRAFANA_SM_STACK_ID", "synthetic-monitoring.logs-id": "GRAFANA_SM_LOGS_ID", "synthetic-monitoring.metrics-id": "GRAFANA_SM_METRICS_ID", + "synthetic-monitoring.url": "GRAFANA_SM_URL", - "mimir.address": "CORTEX_ADDRESS", - "mimir.tenant-id": "CORTEX_TENANT_ID", - "mimir.api-key": "CORTEX_API_KEY", + "mimir.address": "MIMIR_ADDRESS", + "mimir.tenant-id": "MIMIR_TENANT_ID", + "mimir.api-key": "MIMIR_API_KEY", } + + // To keep retro compatibility + legacyBindings := map[string]string{ + "MIMIR_ADDRESS": "CORTEX_ADDRESS", + "MIMIR_TENANT_ID": "CORTEX_TENANT_ID", + "MIMIR_API_KEY": "CORTEX_API_KEY", + } + for key, env := range bindings { - val := os.Getenv(env) - if val != "" { + if val := getVal(env, legacyBindings); val != "" { v.Set(key, val) } } } +func getVal(env string, alternativeMap map[string]string) string { + if val := os.Getenv(env); val != "" { + return val + } + + if alternativeMap[env] != "" { + return getVal(alternativeMap[env], nil) + } + + return "" +} + func Read() error { err := viper.ReadInConfig() if err != nil { @@ -70,7 +89,7 @@ func Mock(values map[string]interface{}) { } func Import() error { - name := viper.GetString(CURRENT_CONTEXT) + name := viper.GetString(CurrentContextSetting) if name == "" { NewConfig() return Import() @@ -90,14 +109,16 @@ func Import() error { func NewConfig() { viper.Set("apiVersion", "v1alpha1") - viper.Set(CURRENT_CONTEXT, "default") + viper.Set(CurrentContextSetting, "default") viper.Set("contexts.default.name", "default") } func GetContexts() error { contexts := map[string]interface{}{} - currentContext := viper.GetString(CURRENT_CONTEXT) - viper.UnmarshalKey("contexts", &contexts) + currentContext := viper.GetString(CurrentContextSetting) + if err := viper.UnmarshalKey("contexts", &contexts); err != nil { + return err + } keys := make([]string, 0, len(contexts)) for k := range contexts { keys = append(keys, k) @@ -115,10 +136,12 @@ func GetContexts() error { func UseContext(context string) error { contexts := map[string]interface{}{} - viper.UnmarshalKey("contexts", &contexts) + if err := viper.UnmarshalKey("contexts", &contexts); err != nil { + return err + } for k := range contexts { if k == context { - viper.Set(CURRENT_CONTEXT, context) + viper.Set(CurrentContextSetting, context) return Write() } } @@ -126,7 +149,7 @@ func UseContext(context string) error { } func CurrentContext() (*Context, error) { - name := viper.GetString(CURRENT_CONTEXT) + name := viper.GetString(CurrentContextSetting) if name == "" { NewConfig() return CurrentContext() @@ -138,7 +161,9 @@ func CurrentContext() (*Context, error) { } override(ctx) var context Context - ctx.Unmarshal(&context) + if err := ctx.Unmarshal(&context); err != nil { + return nil, err + } context.Name = name return &context, nil } @@ -147,6 +172,8 @@ var acceptableKeys = map[string]string{ "grafana.url": "string", "grafana.token": "string", "grafana.user": "string", + "grafana.insecure-skip-verify": "bool", + "grafana.tls-host": "string", "mimir.address": "string", "mimir.tenant-id": "string", "mimir.api-key": "string", @@ -154,13 +181,14 @@ var acceptableKeys = map[string]string{ "synthetic-monitoring.stack-id": "int", "synthetic-monitoring.metrics-id": "int", "synthetic-monitoring.logs-id": "int", + "synthetic-monitoring.url": "string", "targets": "[]string", "output-format": "string", "only-spec": "bool", } func Get(path, outputFormat string) (string, error) { - ctx := viper.GetString(CURRENT_CONTEXT) + ctx := viper.GetString(CurrentContextSetting) fullPath := fmt.Sprintf("contexts.%s", ctx) if path != "" { fullPath = fmt.Sprintf("%s.%s", fullPath, path) @@ -183,7 +211,7 @@ func Get(path, outputFormat string) (string, error) { func Set(path string, value string) error { for key, typ := range acceptableKeys { if path == key { - ctx := viper.GetString(CURRENT_CONTEXT) + ctx := viper.GetString(CurrentContextSetting) fullPath := fmt.Sprintf("contexts.%s.%s", ctx, path) var val any switch typ { @@ -222,7 +250,7 @@ func Unset(path string) error { return fmt.Errorf("%s is not a valid path", path) } - ctx := viper.GetString(CURRENT_CONTEXT) + ctx := viper.GetString(CurrentContextSetting) fullPath := fmt.Sprintf("contexts.%s.%s", ctx, path) if !viper.InConfig(fullPath) { @@ -253,7 +281,7 @@ func deleteValue(settings map[string]any, deleteKey string, iteratorKeys ...stri } func CreateContext(name string) error { - viper.Set(CURRENT_CONTEXT, name) + viper.Set(CurrentContextSetting, name) viper.Set(fmt.Sprintf("contexts.%s.name", name), name) return Write() } diff --git a/pkg/config/model.go b/pkg/config/model.go index 8e1393fb..7dc2bcfa 100644 --- a/pkg/config/model.go +++ b/pkg/config/model.go @@ -1,15 +1,17 @@ package config type GrafanaConfig struct { - URL string `yaml:"url" mapstructure:"url"` - User string `yaml:"user" mapstructure:"user"` - Token string `yaml:"token" mapstructure:"token"` + URL string `yaml:"url" mapstructure:"url"` + User string `yaml:"user" mapstructure:"user"` + Token string `yaml:"token" mapstructure:"token"` + InsecureSkipVerify bool `yaml:"insecure-skip-verify" mapstructure:"insecure-skip-verify"` + TLSHost string `yaml:"tls-host" mapstructure:"tls-host"` } type MimirConfig struct { Address string `yaml:"address" mapstructure:"address"` TenantID string `yaml:"tenant-id" mapstructure:"tenant-id"` - ApiKey string `yaml:"api-key" mapstructure:"api-key"` + APIKey string `yaml:"api-key" mapstructure:"api-key"` } type SyntheticMonitoringConfig struct { @@ -17,6 +19,7 @@ type SyntheticMonitoringConfig struct { StackID int64 `yaml:"stack-id" mapstructure:"stack-id"` LogsID int64 `yaml:"logs-id" mapstructure:"logs-id"` MetricsID int64 `yaml:"metrics-id" mapstructure:"metrics-id"` + URL string `yaml:"url" mapstructure:"url"` } type Context struct { diff --git a/pkg/grafana/alertgroup-handler.go b/pkg/grafana/alertgroup-handler.go index 33fa6efa..bef46895 100644 --- a/pkg/grafana/alertgroup-handler.go +++ b/pkg/grafana/alertgroup-handler.go @@ -59,8 +59,8 @@ func (h *AlertRuleGroupHandler) GetSpecUID(resource grizzly.Resource) (string, e } // GetByUID retrieves JSON for a resource from an endpoint, by UID -func (h *AlertRuleGroupHandler) GetByUID(UID string) (*grizzly.Resource, error) { - return h.getRemoteAlertRuleGroup(UID) +func (h *AlertRuleGroupHandler) GetByUID(uid string) (*grizzly.Resource, error) { + return h.getRemoteAlertRuleGroup(uid) } // GetRemote retrieves a alertRuleGroup as a Resource @@ -146,7 +146,6 @@ func (h *AlertRuleGroupHandler) createAlertRule(rule *models.ProvisionedAlertRul return err } - stringtrue := "true" params := provisioning.NewPostAlertRuleParams().WithBody(rule).WithXDisableProvenance(&stringtrue) _, err = client.Provisioning.PostAlertRule(params, nil) return err @@ -158,7 +157,9 @@ func (h *AlertRuleGroupHandler) createAlertRuleGroup(resource grizzly.Resource) return err } var group models.AlertRuleGroup - err = json.Unmarshal(data, &group) + if err := json.Unmarshal(data, &group); err != nil { + return err + } for _, r := range group.Rules { if err := h.createAlertRule(r); err != nil { @@ -171,7 +172,6 @@ func (h *AlertRuleGroupHandler) createAlertRuleGroup(resource grizzly.Resource) return err } - stringtrue := "true" params := provisioning.NewPutAlertRuleGroupParams(). WithBody(&group). WithGroup(group.Title). @@ -199,7 +199,6 @@ func (h *AlertRuleGroupHandler) updateAlertRule(rule *models.ProvisionedAlertRul return fmt.Errorf("fetching alert rule: %w", err) } } else { - stringtrue := "true" params := provisioning.NewPostAlertRuleParams(). WithBody(rule). WithXDisableProvenance(&stringtrue) @@ -207,7 +206,6 @@ func (h *AlertRuleGroupHandler) updateAlertRule(rule *models.ProvisionedAlertRul return err } - stringtrue := "true" params := provisioning.NewPutAlertRuleParams(). WithUID(rule.UID). WithBody(rule). @@ -272,7 +270,6 @@ func (h *AlertRuleGroupHandler) putAlertRuleGroup(existing, resource grizzly.Res return err } - stringtrue := "true" params := provisioning.NewPutAlertRuleGroupParams(). WithBody(group). WithGroup(group.Title). diff --git a/pkg/grafana/contactpoint-handler.go b/pkg/grafana/contactpoint-handler.go index 5ec09537..7d27c3ef 100644 --- a/pkg/grafana/contactpoint-handler.go +++ b/pkg/grafana/contactpoint-handler.go @@ -57,8 +57,8 @@ func (h *AlertContactPointHandler) GetSpecUID(resource grizzly.Resource) (string } // GetByUID retrieves JSON for a resource from an endpoint, by UID -func (h *AlertContactPointHandler) GetByUID(UID string) (*grizzly.Resource, error) { - return h.getRemoteContactPoint(UID) +func (h *AlertContactPointHandler) GetByUID(uid string) (*grizzly.Resource, error) { + return h.getRemoteContactPoint(uid) } // GetRemote retrieves a contactPoint as a Resource @@ -151,7 +151,6 @@ func (h *AlertContactPointHandler) postContactPoint(resource grizzly.Resource) e if err != nil { return err } - stringtrue := "true" params := provisioning.NewPostContactpointsParams(). WithBody(&contactPoint). WithXDisableProvenance(&stringtrue) @@ -176,7 +175,6 @@ func (h *AlertContactPointHandler) putContactPoint(resource grizzly.Resource) er if err != nil { return err } - stringtrue := "true" params := provisioning.NewPutContactpointParams(). WithUID(resource.Name()). WithBody(&modelContactPoint). diff --git a/pkg/grafana/dashboard-handler.go b/pkg/grafana/dashboard-handler.go index 5db2959f..f9229fdb 100644 --- a/pkg/grafana/dashboard-handler.go +++ b/pkg/grafana/dashboard-handler.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "strings" "github.com/go-chi/chi" "github.com/grafana/grafana-openapi-client-go/client/dashboards" @@ -17,7 +18,7 @@ import ( ) // Moved from utils.go -const generalFolderId = 0 +const generalFolderID = 0 const generalFolderUID = "general" // DashboardHandler is a Grizzly Handler for Grafana dashboards @@ -77,10 +78,10 @@ func (h *DashboardHandler) GetSpecUID(resource grizzly.Resource) (string, error) } // GetByUID retrieves JSON for a resource from an endpoint, by UID -func (h *DashboardHandler) GetByUID(UID string) (*grizzly.Resource, error) { - resource, err := h.getRemoteDashboard(UID) +func (h *DashboardHandler) GetByUID(uid string) (*grizzly.Resource, error) { + resource, err := h.getRemoteDashboard(uid) if err != nil { - return nil, fmt.Errorf("Error retrieving dashboard %s: %w", UID, err) + return nil, fmt.Errorf("Error retrieving dashboard %s: %w", uid, err) } return resource, nil } @@ -152,8 +153,8 @@ func (h *DashboardHandler) getRemoteDashboard(uid string) (*grizzly.Resource, er if err != nil { return nil, err } - folderUid := extractFolderUID(client, *dashboard) - resource.SetMetadata("folder", folderUid) + folderUID := extractFolderUID(client, *dashboard) + resource.SetMetadata("folder", folderUID) return &resource, nil } @@ -192,7 +193,7 @@ func (h *DashboardHandler) getRemoteDashboardList() ([]string, error) { func (h *DashboardHandler) postDashboard(resource grizzly.Resource) error { folderUID := resource.GetMetadata("folder") var folderID int64 - if !(folderUID == "General" || folderUID == "general") { + if !(folderUID == DefaultFolder || folderUID == strings.ToLower(DefaultFolder)) { folderHandler := NewFolderHandler(h.Provider) folder, err := folderHandler.getRemoteFolder(folderUID) if err != nil { @@ -204,7 +205,7 @@ func (h *DashboardHandler) postDashboard(resource grizzly.Resource) error { } folderID = int64(folder.GetSpecValue("id").(float64)) } else { - folderID = generalFolderId + folderID = generalFolderID } body := models.SaveDashboardCommand{ @@ -259,22 +260,22 @@ func (h *DashboardHandler) GetProxyEndpoints(p grizzly.Server) []grizzly.ProxyEn return []grizzly.ProxyEndpoint{ { Method: "GET", - Url: "/d/{uid}/{slug}", + URL: "/d/{uid}/{slug}", Handler: h.resourceFromQueryParameterMiddleware(p, "grizzly_from_file", h.RootDashboardPageHandler(p)), }, { Method: "GET", - Url: "/api/dashboards/uid/{uid}", + URL: "/api/dashboards/uid/{uid}", Handler: h.DashboardJSONGetHandler(p), }, { Method: "POST", - Url: "/api/dashboards/db", + URL: "/api/dashboards/db", Handler: h.DashboardJSONPostHandler(p), }, { Method: "POST", - Url: "/api/dashboards/db/", + URL: "/api/dashboards/db/", Handler: h.DashboardJSONPostHandler(p), }, } @@ -314,7 +315,7 @@ func (h *DashboardHandler) RootDashboardPageHandler(p grizzly.Server) http.Handl if err == nil { body, _ := io.ReadAll(resp.Body) - w.Write(body) + writeOrLog(w, body) return } @@ -363,7 +364,7 @@ func (h *DashboardHandler) DashboardJSONGetHandler(p grizzly.Server) http.Handle } out, _ := json.Marshal(wrapper) - w.Write(out) + writeOrLog(w, out) } } @@ -426,7 +427,7 @@ func (h *DashboardHandler) DashboardJSONPostHandler(p grizzly.Server) http.Handl "version": 1, } body, _ := json.Marshal(jout) - w.Write(body) + writeOrLog(w, body) } } diff --git a/pkg/grafana/datasource-handler.go b/pkg/grafana/datasource-handler.go index 18153db3..f697f00e 100644 --- a/pkg/grafana/datasource-handler.go +++ b/pkg/grafana/datasource-handler.go @@ -72,8 +72,8 @@ func (h *DatasourceHandler) GetSpecUID(resource grizzly.Resource) (string, error } // GetByUID retrieves JSON for a resource from an endpoint, by UID -func (h *DatasourceHandler) GetByUID(UID string) (*grizzly.Resource, error) { - return h.getRemoteDatasource(UID) +func (h *DatasourceHandler) GetByUID(uid string) (*grizzly.Resource, error) { + return h.getRemoteDatasource(uid) } // GetRemote retrieves a datasource as a Resource diff --git a/pkg/grafana/errors.go b/pkg/grafana/errors.go index 7c273fb3..7eb5d2c7 100644 --- a/pkg/grafana/errors.go +++ b/pkg/grafana/errors.go @@ -2,7 +2,10 @@ package grafana import ( "fmt" + "net/http" "strings" + + log "github.com/sirupsen/logrus" ) // ErrUidsMissing reports UIDs are missing for Dashboards @@ -17,3 +20,9 @@ type APIResponse interface { Error() string String() string } + +func writeOrLog(w http.ResponseWriter, content []byte) { + if _, err := w.Write(content); err != nil { + log.Errorf("error writing response: %v", err) + } +} diff --git a/pkg/grafana/folder-handler.go b/pkg/grafana/folder-handler.go index 39c96588..02fa44e9 100644 --- a/pkg/grafana/folder-handler.go +++ b/pkg/grafana/folder-handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" gclient "github.com/grafana/grafana-openapi-client-go/client" "github.com/grafana/grafana-openapi-client-go/client/folders" @@ -12,6 +13,8 @@ import ( "github.com/grafana/grizzly/pkg/grizzly" ) +const DefaultFolder = "General" + // FolderHandler is a Grizzly Handler for Grafana dashboard folders type FolderHandler struct { grizzly.BaseHandler @@ -115,10 +118,10 @@ func (h *FolderHandler) Sort(resources grizzly.Resources) grizzly.Resources { } // GetByUID retrieves JSON for a resource from an endpoint, by UID -func (h *FolderHandler) GetByUID(UID string) (*grizzly.Resource, error) { - resource, err := h.getRemoteFolder(UID) +func (h *FolderHandler) GetByUID(uid string) (*grizzly.Resource, error) { + resource, err := h.getRemoteFolder(uid) if err != nil { - return nil, fmt.Errorf("Error retrieving dashboard folder %s: %w", UID, err) + return nil, fmt.Errorf("Error retrieving dashboard folder %s: %w", uid, err) } return resource, nil @@ -147,14 +150,14 @@ func (h *FolderHandler) Update(existing, resource grizzly.Resource) error { // getRemoteFolder retrieves a folder object from Grafana func (h *FolderHandler) getRemoteFolder(uid string) (*grizzly.Resource, error) { if uid == "" { - return nil, fmt.Errorf("No folder UID provided") + return nil, fmt.Errorf("no folder UID provided") } var folder *models.Folder - if uid == "General" || uid == "general" { + if uid == DefaultFolder || uid == strings.ToLower(DefaultFolder) { folder = &models.Folder{ ID: 0, UID: uid, - Title: "General", + Title: DefaultFolder, // URL: ?? } } else { @@ -168,7 +171,7 @@ func (h *FolderHandler) getRemoteFolder(uid string) (*grizzly.Resource, error) { var gErrNotFound *folders.GetFolderByUIDNotFound var gErrForbidden *folders.GetFolderByUIDForbidden if errors.As(err, &gErrNotFound) || errors.As(err, &gErrForbidden) { - return nil, fmt.Errorf("Couldn't fetch folder '%s' from remote: %w", uid, grizzly.ErrNotFound) + return nil, fmt.Errorf("couldn't fetch folder '%s' from remote: %w", uid, grizzly.ErrNotFound) } return nil, err } @@ -193,7 +196,7 @@ func (h *FolderHandler) getRemoteFolderList() ([]string, error) { limit = int64(1000) page int64 = 0 uids []string - folderType string = "dash-folder" + folderType = "dash-folder" ) params := search.NewSearchParams().WithLimit(&limit) @@ -223,7 +226,7 @@ func (h *FolderHandler) getRemoteFolderList() ([]string, error) { func (h *FolderHandler) postFolder(resource grizzly.Resource) error { name := resource.Name() - if name == "General" || name == "general" { + if name == DefaultFolder || name == strings.ToLower(DefaultFolder) { return nil } @@ -285,7 +288,7 @@ func (h *FolderHandler) putFolder(resource grizzly.Resource) error { return err } -var getFolderById = func(client *gclient.GrafanaHTTPAPI, folderId int64) (*models.Folder, error) { +var getFolderByID = func(client *gclient.GrafanaHTTPAPI, folderId int64) (*models.Folder, error) { folderOk, err := client.Folders.GetFolderByID(folderId) if err != nil { return nil, err diff --git a/pkg/grafana/library-element-handler.go b/pkg/grafana/library-element-handler.go index e1c4fc17..d5f587dc 100644 --- a/pkg/grafana/library-element-handler.go +++ b/pkg/grafana/library-element-handler.go @@ -86,10 +86,10 @@ func (h *LibraryElementHandler) GetSpecUID(resource grizzly.Resource) (string, e } // GetByUID retrieves JSON for a resource from an endpoint, by UID -func (h *LibraryElementHandler) GetByUID(UID string) (*grizzly.Resource, error) { - resource, err := h.getRemoteLibraryElement(UID) +func (h *LibraryElementHandler) GetByUID(uid string) (*grizzly.Resource, error) { + resource, err := h.getRemoteLibraryElement(uid) if err != nil { - return nil, fmt.Errorf("Error retrieving library element %s: %w", UID, err) + return nil, fmt.Errorf("Error retrieving library element %s: %w", uid, err) } return resource, nil diff --git a/pkg/grafana/notificationpolicy-handler.go b/pkg/grafana/notificationpolicy-handler.go index fa4e5477..f9c5571c 100644 --- a/pkg/grafana/notificationpolicy-handler.go +++ b/pkg/grafana/notificationpolicy-handler.go @@ -47,7 +47,7 @@ func (h *AlertNotificationPolicyHandler) GetSpecUID(resource grizzly.Resource) ( } // GetByUID retrieves JSON for a resource from an endpoint, by UID -func (h *AlertNotificationPolicyHandler) GetByUID(UID string) (*grizzly.Resource, error) { +func (h *AlertNotificationPolicyHandler) GetByUID(uid string) (*grizzly.Resource, error) { return h.getRemoteAlertNotificationPolicy() } @@ -117,7 +117,6 @@ func (h *AlertNotificationPolicyHandler) putAlertNotificationPolicy(resource gri if err != nil { return err } - stringtrue := "true" params := provisioning.NewPutPolicyTreeParams(). WithBody(&alertNotificationPolicy). WithXDisableProvenance(&stringtrue) diff --git a/pkg/grafana/provider.go b/pkg/grafana/provider.go index f18dd0cf..8cb90b69 100644 --- a/pkg/grafana/provider.go +++ b/pkg/grafana/provider.go @@ -1,6 +1,7 @@ package grafana import ( + "crypto/tls" "encoding/base64" "fmt" "net/http/httputil" @@ -52,15 +53,22 @@ func (p *Provider) Client() (*gclient.GrafanaHTTPAPI, error) { return p.client, nil } - parsedUrl, err := url.Parse(p.config.URL) + parsedURL, err := url.Parse(p.config.URL) if err != nil { return nil, fmt.Errorf("invalid Grafana URL") } transportConfig := gclient.DefaultTransportConfig(). - WithHost(parsedUrl.Host). - WithSchemes([]string{parsedUrl.Scheme}). - WithBasePath(filepath.Join(parsedUrl.Path, "api")) + WithHost(parsedURL.Host). + WithSchemes([]string{parsedURL.Scheme}). + WithBasePath(filepath.Join(parsedURL.Path, "api")) + + if parsedURL.Scheme == "https" && p.config.InsecureSkipVerify { + transportConfig.TLSConfig = &tls.Config{ + InsecureSkipVerify: true, + ServerName: p.config.TLSHost, + } + } if p.config.Token != "" { if p.config.User != "" { diff --git a/pkg/grafana/utils.go b/pkg/grafana/utils.go index f5ff6f5f..d7120a46 100644 --- a/pkg/grafana/utils.go +++ b/pkg/grafana/utils.go @@ -8,25 +8,28 @@ import ( "github.com/grafana/grafana-openapi-client-go/models" ) -var folderURLRegex = regexp.MustCompile("/dashboards/f/([^/]+)") +var ( + stringtrue = "true" + folderURLRegex = regexp.MustCompile("/dashboards/f/([^/]+)") +) func extractFolderUID(client *gclient.GrafanaHTTPAPI, d models.DashboardFullWithMeta) string { - folderUid := d.Meta.FolderUID - if folderUid == "" { + folderUID := d.Meta.FolderUID + if folderUID == "" { urlPaths := folderURLRegex.FindStringSubmatch(d.Meta.FolderURL) if len(urlPaths) == 0 { - if d.Meta.FolderID == generalFolderId { + if d.Meta.FolderID == generalFolderID { // nolint:staticcheck return generalFolderUID } - folder, err := getFolderById(client, d.Meta.FolderID) + folder, err := getFolderByID(client, d.Meta.FolderID) // nolint:staticcheck if err != nil { return "" } return folder.UID } - folderUid = urlPaths[1] + folderUID = urlPaths[1] } - return folderUid + return folderUID } func structToMap(s interface{}) (map[string]interface{}, error) { diff --git a/pkg/grafana/utils_test.go b/pkg/grafana/utils_test.go index 63d13e5a..639f3dce 100644 --- a/pkg/grafana/utils_test.go +++ b/pkg/grafana/utils_test.go @@ -45,7 +45,7 @@ func TestExtractFolderUID(t *testing.T) { dashboard := models.DashboardFullWithMeta{ Meta: &meta, } - getFolderById = func(client *gclient.GrafanaHTTPAPI, folderId int64) (*models.Folder, error) { + getFolderByID = func(client *gclient.GrafanaHTTPAPI, folderId int64) (*models.Folder, error) { return &models.Folder{ UID: "12345", }, nil diff --git a/pkg/grizzly/formatting.go b/pkg/grizzly/formatting.go index e69eb67c..57ad3990 100644 --- a/pkg/grizzly/formatting.go +++ b/pkg/grizzly/formatting.go @@ -8,6 +8,13 @@ import ( "gopkg.in/yaml.v3" ) +const ( + formatJSON = "json" + formatYAML = "yaml" + formatWide = "wide" + formatDefault = "default" +) + func Format(registry Registry, resourcePath string, resource *Resource, format string, onlySpec bool) ([]byte, string, string, error) { var content []byte var filename string @@ -19,15 +26,15 @@ func Format(registry Registry, resourcePath string, resource *Resource, format s spec = resource.Spec() } - if format == "json" { - extension = "json" + if format == formatJSON { + extension = formatJSON j, err := json.MarshalIndent(spec, "", " ") if err != nil { return nil, "", "", err } content = j } else { - extension = "yaml" + extension = formatYAML y, err := yaml.Marshal(spec) if err != nil { return nil, "", "", err diff --git a/pkg/grizzly/handler.go b/pkg/grizzly/handler.go index 94ddf2b2..3aedcb57 100644 --- a/pkg/grizzly/handler.go +++ b/pkg/grizzly/handler.go @@ -114,7 +114,7 @@ type ListenHandler interface { type ProxyEndpoint struct { Method string - Url string + URL string Handler func(http.ResponseWriter, *http.Request) } diff --git a/pkg/grizzly/json.go b/pkg/grizzly/json.go index 59a0846a..20a55a05 100644 --- a/pkg/grizzly/json.go +++ b/pkg/grizzly/json.go @@ -35,7 +35,7 @@ func (parser *JSONParser) Parse(file string, options ParserOptions) (Resources, } source := Source{ - Format: "json", + Format: formatJSON, Path: file, Rewritable: true, } diff --git a/pkg/grizzly/parsing.go b/pkg/grizzly/parsing.go index f96bf9f0..23f4f645 100644 --- a/pkg/grizzly/parsing.go +++ b/pkg/grizzly/parsing.go @@ -288,10 +288,8 @@ func ValidateEnvelope(data any) error { s, ok := spec.(map[string]any) if !ok { errors = append(errors, "spec is not a map") - } else { - if len(s) == 0 { - errors = append(errors, "spec should not be empty") - } + } else if len(s) == 0 { + errors = append(errors, "spec should not be empty") } } diff --git a/pkg/grizzly/parsing_test.go b/pkg/grizzly/parsing_test.go index ff2960d2..af434b7a 100644 --- a/pkg/grizzly/parsing_test.go +++ b/pkg/grizzly/parsing_test.go @@ -199,7 +199,7 @@ func TestParseKindDetection(t *testing.T) { parser := grizzly.DefaultParser(registry, nil, nil) parseOpts := grizzly.ParserOptions{ DefaultResourceKind: "", - DefaultFolderUID: "General", + DefaultFolderUID: grafana.DefaultFolder, } for _, test := range tests { diff --git a/pkg/grizzly/registry.go b/pkg/grizzly/registry.go index 45b23b93..33439300 100644 --- a/pkg/grizzly/registry.go +++ b/pkg/grizzly/registry.go @@ -73,14 +73,14 @@ func (r *Registry) HandlerMatchesTarget(handler Handler, targets []string) bool } // ResourceMatchesTarget identifies whether a resource is in a target list -func (r *Registry) ResourceMatchesTarget(kind string, UID string, targets []string) bool { +func (r *Registry) ResourceMatchesTarget(kind string, uid string, targets []string) bool { if len(targets) == 0 { return true } // I mistakenly assumed 'dot' was a special character for globs, so opted for '/' as separator. // This keeps back-compat - slashKey := fmt.Sprintf("%s/%s", kind, UID) - dotKey := fmt.Sprintf("%s.%s", kind, UID) + slashKey := fmt.Sprintf("%s/%s", kind, uid) + dotKey := fmt.Sprintf("%s.%s", kind, uid) for _, target := range targets { if strings.Contains(target, ".") || strings.Contains(target, "/") { g, err := glob.Compile(target) @@ -131,7 +131,7 @@ func (r *Registry) GetProxyProvider() (*ProxyProvider, error) { if proxyProvider == nil { proxyProvider = &pp } else { - return nil, fmt.Errorf("Only one proxy provider currently supported") + return nil, fmt.Errorf("only one proxy provider currently supported") } } } diff --git a/pkg/grizzly/resources.go b/pkg/grizzly/resources.go index cfee98b8..bcca822b 100644 --- a/pkg/grizzly/resources.go +++ b/pkg/grizzly/resources.go @@ -170,7 +170,6 @@ func (r *Resource) SpecAsJSON() (string, error) { return "", err } return string(j), nil - } // YAML Gets the string representation for this resource diff --git a/pkg/grizzly/server.go b/pkg/grizzly/server.go index ee954bc2..d4e9b397 100644 --- a/pkg/grizzly/server.go +++ b/pkg/grizzly/server.go @@ -122,9 +122,9 @@ func (p *Server) Start() error { for _, endpoint := range proxyHandler.GetProxyEndpoints(*p) { switch endpoint.Method { case "GET": - r.Get(endpoint.Url, endpoint.Handler) + r.Get(endpoint.URL, endpoint.Handler) case "POST": - r.Post(endpoint.Url, endpoint.Handler) + r.Post(endpoint.URL, endpoint.Handler) default: return fmt.Errorf("unknown endpoint method %s for handler %s", endpoint.Method, handler.Kind()) } @@ -233,7 +233,9 @@ func (p *Server) blockHandler(response string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(response)) + if _, err := w.Write([]byte(response)); err != nil { + log.Errorf("error writing response: %v", err) + } } } diff --git a/pkg/grizzly/workflow.go b/pkg/grizzly/workflow.go index c9a80263..ccf12441 100644 --- a/pkg/grizzly/workflow.go +++ b/pkg/grizzly/workflow.go @@ -17,7 +17,7 @@ import ( "github.com/hashicorp/go-multierror" "github.com/pmezard/go-difflib/difflib" log "github.com/sirupsen/logrus" - "golang.org/x/crypto/ssh/terminal" + terminal "golang.org/x/term" "gopkg.in/fsnotify.v1" "gopkg.in/yaml.v3" ) @@ -25,22 +25,22 @@ import ( var interactive = terminal.IsTerminal(int(os.Stdout.Fd())) // Get retrieves a resource from a remote endpoint using its UID -func Get(registry Registry, UID string, onlySpec bool, outputFormat string) error { - log.Info("Getting ", UID) +func Get(registry Registry, uid string, onlySpec bool, outputFormat string) error { + log.Info("Getting ", uid) - count := strings.Count(UID, ".") + count := strings.Count(uid, ".") var handlerName, resourceID string - if count == 1 { - parts := strings.SplitN(UID, ".", 2) + switch count { + case 1: + parts := strings.SplitN(uid, ".", 2) handlerName = parts[0] resourceID = parts[1] - } else if count == 2 { - parts := strings.SplitN(UID, ".", 3) + case 2: + parts := strings.SplitN(uid, ".", 3) handlerName = parts[0] + "." + parts[1] resourceID = parts[2] - - } else { - return fmt.Errorf("UID must be .: %s", UID) + default: + return fmt.Errorf("UID must be .: %s", uid) } handler, err := registry.GetHandler(handlerName) @@ -124,13 +124,13 @@ func listResources(listedResources []listedResource, format string) error { var output []byte var err error switch format { - case "yaml": + case formatYAML: output, err = yaml.Marshal(listedResources) - case "json": + case formatJSON: output, err = json.MarshalIndent(listedResources, " ", "") - case "default": + case formatDefault: output, err = listDefault(listedResources) - case "wide": + case formatWide: output, err = listWide(listedResources) } if err != nil { @@ -141,7 +141,6 @@ func listResources(listedResources []listedResource, format string) error { } func listDefault(listedResources []listedResource) ([]byte, error) { - var out bytes.Buffer var f string w := tabwriter.NewWriter(&out, 0, 0, 4, ' ', 0) @@ -157,7 +156,6 @@ func listDefault(listedResources []listedResource) ([]byte, error) { } func listWide(listedResources []listedResource) ([]byte, error) { - var out bytes.Buffer var f string w := tabwriter.NewWriter(&out, 0, 0, 4, ' ', 0) diff --git a/pkg/grizzly/yaml.go b/pkg/grizzly/yaml.go index 19478c08..d378c6fd 100644 --- a/pkg/grizzly/yaml.go +++ b/pkg/grizzly/yaml.go @@ -47,7 +47,7 @@ func (parser *YAMLParser) Parse(file string, options ParserOptions) (Resources, } source := Source{ - Format: "yaml", + Format: formatYAML, Path: file, Rewritable: true, } diff --git a/pkg/mimir/client/http_client.go b/pkg/mimir/client/http_client.go new file mode 100644 index 00000000..ccf341ea --- /dev/null +++ b/pkg/mimir/client/http_client.go @@ -0,0 +1,130 @@ +package client + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "time" + + "github.com/grafana/grizzly/pkg/config" + "github.com/grafana/grizzly/pkg/mimir/models" + "gopkg.in/yaml.v3" +) + +var loadRulesEndpoint = "%s/prometheus/config/v1/rules/%s" +var listRulesEndpoint = "%s/prometheus/api/v1/rules" + +type ListGroupResponse struct { + Status string `yaml:"status"` + Data struct { + DataGroups []DataGroups `yaml:"groups"` + } `yaml:"data"` +} + +type DataGroups struct { + Name string `yaml:"name"` + File string `yaml:"file"` + Rules []interface{} `yaml:"rules"` +} + +type Client struct { + config *config.MimirConfig +} + +func NewHTTPClient(config *config.MimirConfig) Mimir { + return &Client{config: config} +} + +func (c *Client) ListRules() (map[string][]models.PrometheusRuleGroup, error) { + url := fmt.Sprintf(listRulesEndpoint, c.config.Address) + res, err := c.doRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + var response ListGroupResponse + if err := yaml.Unmarshal(res, &response); err != nil { + return nil, err + } + + groups := make(map[string][]models.PrometheusRuleGroup) + for _, g := range response.Data.DataGroups { + groups[g.File] = append(groups[g.File], models.PrometheusRuleGroup{ + Name: g.Name, + Rules: g.Rules, + }) + } + + return groups, nil +} + +func (c *Client) CreateRules(resource models.PrometheusRuleGrouping) error { + url := fmt.Sprintf(loadRulesEndpoint, c.config.Address, resource.Namespace) + for _, group := range resource.Groups { + out, err := yaml.Marshal(group) + if err != nil { + return fmt.Errorf("cannot marshall groups: %s", err) + } + + if _, err = c.doRequest(http.MethodPost, url, out); err != nil { + return fmt.Errorf("error found creating rule group: %s", group.Name) + } + } + + return nil +} + +func (c *Client) doRequest(method string, url string, body []byte) ([]byte, error) { + if c.config.TenantID == "" { + return nil, errors.New("missing tenant-id") + } + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/yaml") + if c.config.APIKey != "" { + req.SetBasicAuth(c.config.TenantID, c.config.APIKey) + } else { + req.Header.Set("X-Scope-OrgID", c.config.TenantID) + } + + client, err := createHTTPClient() + if err != nil { + return nil, err + } + + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request to load rules failed: %s", err) + } + + if res.StatusCode >= 300 { + return nil, fmt.Errorf("error loading rules: %d", res.StatusCode) + } + + b, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("cannot read response body: %s", err) + } + + return b, nil +} + +func createHTTPClient() (*http.Client, error) { + timeout := 10 * time.Second + // TODO: Move this configuration to the global configuration + if timeoutStr := os.Getenv("GRIZZLY_HTTP_TIMEOUT"); timeoutStr != "" { + timeoutSeconds, err := strconv.Atoi(timeoutStr) + if err != nil { + return nil, err + } + timeout = time.Duration(timeoutSeconds) * time.Second + } + return &http.Client{Timeout: timeout}, nil +} diff --git a/pkg/mimir/client/mimir.go b/pkg/mimir/client/mimir.go new file mode 100644 index 00000000..ccbc2976 --- /dev/null +++ b/pkg/mimir/client/mimir.go @@ -0,0 +1,10 @@ +package client + +import ( + "github.com/grafana/grizzly/pkg/mimir/models" +) + +type Mimir interface { + ListRules() (map[string][]models.PrometheusRuleGroup, error) + CreateRules(resource models.PrometheusRuleGrouping) error +} diff --git a/pkg/mimir/cortex_tool.go b/pkg/mimir/cortex_tool.go deleted file mode 100644 index 23b85810..00000000 --- a/pkg/mimir/cortex_tool.go +++ /dev/null @@ -1,39 +0,0 @@ -package mimir - -import ( - "fmt" - "os" - "os/exec" - - "github.com/grafana/grizzly/pkg/config" -) - -type CortexTool interface { - ExecuteCortexTool(args ...string) ([]byte, error) -} - -type Cortex struct { - config *config.MimirConfig -} - -func NewCortexTool(config *config.MimirConfig) *Cortex { - return &Cortex{config: config} -} - -func (c *Cortex) ExecuteCortexTool(args ...string) ([]byte, error) { - path := os.Getenv("CORTEXTOOL_PATH") - if path == "" { - var err error - path, err = exec.LookPath("cortextool") - if err != nil { - return nil, err - } else if path == "" { - return nil, fmt.Errorf("cortextool not found") - } - } - cmd := exec.Command(path, args...) - cmd.Env = append(cmd.Env, fmt.Sprintf("CORTEX_ADDRESS=%s", c.config.Address)) - cmd.Env = append(cmd.Env, fmt.Sprintf("CORTEX_TENANT_ID=%s", c.config.TenantID)) - cmd.Env = append(cmd.Env, fmt.Sprintf("CORTEX_API_KEY=%s", c.config.ApiKey)) - return cmd.Output() -} diff --git a/pkg/mimir/models/models.go b/pkg/mimir/models/models.go new file mode 100644 index 00000000..2d368274 --- /dev/null +++ b/pkg/mimir/models/models.go @@ -0,0 +1,13 @@ +package models + +// PrometheusRuleGroup encapsulates a list of rules +type PrometheusRuleGroup struct { + Name string `yaml:"name"` + Rules []interface{} `yaml:"rules"` +} + +// PrometheusRuleGrouping encapsulates a set of named rule groups +type PrometheusRuleGrouping struct { + Namespace string `yaml:"namespace"` + Groups []PrometheusRuleGroup `yaml:"groups"` +} diff --git a/pkg/mimir/provider.go b/pkg/mimir/provider.go index 563ac615..acf7cd67 100644 --- a/pkg/mimir/provider.go +++ b/pkg/mimir/provider.go @@ -2,32 +2,32 @@ package mimir import ( "fmt" - "os/exec" "path/filepath" "github.com/grafana/grizzly/pkg/config" "github.com/grafana/grizzly/pkg/grizzly" + "github.com/grafana/grizzly/pkg/mimir/client" ) // Provider is a grizzly.Provider implementation for Grafana. type Provider struct { - config *config.MimirConfig + config *config.MimirConfig + clientTool client.Mimir } // NewProvider instantiates a new Provider. func NewProvider(config *config.MimirConfig) (*Provider, error) { - if _, err := exec.LookPath("cortextool"); err != nil { - return nil, err - } + clientTool := client.NewHTTPClient(config) if config.Address == "" { return nil, fmt.Errorf("mimir address is not set") } - if config.ApiKey == "" { - return nil, fmt.Errorf("mimir api key is not set") + if config.TenantID == "" { + return nil, fmt.Errorf("mimir tenant id is not set") } return &Provider{ - config: config, + config: config, + clientTool: clientTool, }, nil } @@ -53,6 +53,6 @@ func (p *Provider) APIVersion() string { // GetHandlers identifies the handlers for the Grafana provider func (p *Provider) GetHandlers() []grizzly.Handler { return []grizzly.Handler{ - NewRuleHandler(p), + NewRuleHandler(p, p.clientTool), } } diff --git a/pkg/mimir/rules-handler.go b/pkg/mimir/rules-handler.go index 03997d04..a3e2bf35 100644 --- a/pkg/mimir/rules-handler.go +++ b/pkg/mimir/rules-handler.go @@ -2,25 +2,25 @@ package mimir import ( "fmt" - "log" - "os" "strings" + "github.com/grafana/grizzly/pkg/mimir/client" + "github.com/grafana/grizzly/pkg/mimir/models" + "github.com/grafana/grizzly/pkg/grizzly" - "gopkg.in/yaml.v3" ) // RuleHandler is a Grizzly Handler for Prometheus Rules type RuleHandler struct { grizzly.BaseHandler - cortexTool CortexTool + clientTool client.Mimir } // NewRuleHandler returns a new Grizzly Handler for Prometheus Rules -func NewRuleHandler(provider *Provider) *RuleHandler { +func NewRuleHandler(provider *Provider, clientTool client.Mimir) *RuleHandler { return &RuleHandler{ BaseHandler: grizzly.NewBaseHandler(provider, "PrometheusRuleGroup", false), - cortexTool: NewCortexTool(provider.config), + clientTool: clientTool, } } @@ -55,8 +55,8 @@ func (h *RuleHandler) GetSpecUID(resource grizzly.Resource) (string, error) { } // GetByUID retrieves JSON for a resource from an endpoint, by UID -func (h *RuleHandler) GetByUID(UID string) (*grizzly.Resource, error) { - return h.getRemoteRuleGroup(UID) +func (h *RuleHandler) GetByUID(uid string) (*grizzly.Resource, error) { + return h.getRemoteRuleGroup(uid) } // GetRemote retrieves a datasource as a Resource @@ -86,15 +86,11 @@ func (h *RuleHandler) getRemoteRuleGroup(uid string) (*grizzly.Resource, error) namespace := parts[0] name := parts[1] - out, err := h.cortexTool.ExecuteCortexTool("rules", "print", "--disable-color") - if err != nil { - return nil, err - } - groupings := map[string][]PrometheusRuleGroup{} - err = yaml.Unmarshal(out, &groupings) + groupings, err := h.clientTool.ListRules() if err != nil { return nil, err } + for key, grouping := range groupings { if key == namespace { for _, group := range grouping { @@ -117,17 +113,12 @@ func (h *RuleHandler) getRemoteRuleGroup(uid string) (*grizzly.Resource, error) // getRemoteRuleGroupList retrieves a datasource object from Grafana func (h *RuleHandler) getRemoteRuleGroupList() ([]string, error) { - out, err := h.cortexTool.ExecuteCortexTool("rules", "print", "--disable-color") - if err != nil { - return nil, err - } - groupings := map[string][]PrometheusRuleGroup{} - err = yaml.Unmarshal(out, &groupings) + groupings, err := h.clientTool.ListRules() if err != nil { return nil, err } - IDs := []string{} + var IDs []string for namespace, grouping := range groupings { for _, group := range grouping { uid := fmt.Sprintf("%s.%s", namespace, group.Name) @@ -137,49 +128,20 @@ func (h *RuleHandler) getRemoteRuleGroupList() ([]string, error) { return IDs, nil } -// PrometheusRuleGroup encapsulates a list of rules -type PrometheusRuleGroup struct { - Namespace string `yaml:"-"` - Name string `yaml:"name"` - Rules []map[string]interface{} `yaml:"rules"` -} - -// PrometheusRuleGrouping encapsulates a set of named rule groups -type PrometheusRuleGrouping struct { - Namespace string `json:"namespace"` - Groups []PrometheusRuleGroup `json:"groups"` -} - func (h *RuleHandler) writeRuleGroup(resource grizzly.Resource) error { - tmpfile, err := os.CreateTemp("", "cortextool-*") - if err != nil { - return err - } - newGroup := PrometheusRuleGroup{ - Name: resource.Name(), - // Rules: resource.Spec()["rules"].([]map[string]interface{}), - Rules: []map[string]interface{}{}, + newGroup := models.PrometheusRuleGroup{ + Name: resource.Name(), + Rules: []interface{}{}, } rules := resource.Spec()["rules"].([]interface{}) for _, ruleIf := range rules { rule := ruleIf.(map[string]interface{}) newGroup.Rules = append(newGroup.Rules, rule) } - grouping := PrometheusRuleGrouping{ + grouping := models.PrometheusRuleGrouping{ Namespace: resource.GetMetadata("namespace"), - Groups: []PrometheusRuleGroup{newGroup}, + Groups: []models.PrometheusRuleGroup{newGroup}, } - out, err := yaml.Marshal(grouping) - if err != nil { - return err - } - os.WriteFile(tmpfile.Name(), out, 0644) - output, err := h.cortexTool.ExecuteCortexTool("rules", "load", tmpfile.Name()) - if err != nil { - log.Println(output) - return err - } - os.Remove(tmpfile.Name()) - return err + return h.clientTool.CreateRules(grouping) } diff --git a/pkg/mimir/rules_test.go b/pkg/mimir/rules_test.go index 8c21df0f..80501042 100644 --- a/pkg/mimir/rules_test.go +++ b/pkg/mimir/rules_test.go @@ -6,21 +6,21 @@ import ( "testing" "github.com/grafana/grizzly/pkg/grizzly" + "github.com/grafana/grizzly/pkg/mimir/models" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" ) -var errCortextoolClient = errors.New("error coming from cortextool client") +var errMimirClient = errors.New("error coming from mimir client") func TestRules(t *testing.T) { - cortexTool := &FakeCortexTool{} + client := &FakeClient{} h := RuleHandler{ BaseHandler: grizzly.NewBaseHandler(&Provider{}, "PrometheusRuleGroup", false), - cortexTool: cortexTool, + clientTool: client, } t.Run("get remote rule group", func(t *testing.T) { - cortexTool.mockResponse(t, true, nil) + client.mockResponse(t, true, nil) res, err := h.getRemoteRuleGroup("first_rules.grizzly_alerts") require.NoError(t, err) uid, err := h.GetUID(*res) @@ -31,36 +31,36 @@ func TestRules(t *testing.T) { require.Equal(t, "PrometheusRuleGroup", res.Kind()) }) - t.Run("get remote rule group - error from cortextool client", func(t *testing.T) { - cortexTool.mockResponse(t, false, errCortextoolClient) + t.Run("get remote rule group - error from mimir client", func(t *testing.T) { + client.mockResponse(t, false, errMimirClient) res, err := h.getRemoteRuleGroup("first_rules.grizzly_alerts") require.Error(t, err) require.Nil(t, res) }) t.Run("get remote rule group - return not found", func(t *testing.T) { - cortexTool.mockResponse(t, true, nil) + client.mockResponse(t, true, nil) res, err := h.getRemoteRuleGroup("name.name") require.Error(t, err) require.Nil(t, res) }) t.Run("get remote rule group list", func(t *testing.T) { - cortexTool.mockResponse(t, true, nil) + client.mockResponse(t, true, nil) res, err := h.getRemoteRuleGroupList() require.NoError(t, err) require.Equal(t, "first_rules.grizzly_alerts", res[0]) }) t.Run("get remote rule group list", func(t *testing.T) { - cortexTool.mockResponse(t, false, errCortextoolClient) + client.mockResponse(t, false, errMimirClient) res, err := h.getRemoteRuleGroupList() require.Error(t, err) require.Nil(t, res) }) t.Run("write rule group", func(t *testing.T) { - cortexTool.mockResponse(t, false, nil) + client.mockResponse(t, false, nil) spec := make(map[string]interface{}) file, err := os.ReadFile("testdata/rules.yaml") require.NoError(t, err) @@ -73,8 +73,8 @@ func TestRules(t *testing.T) { require.NoError(t, err) }) - t.Run("write rule group - error from the cortextool client", func(t *testing.T) { - cortexTool.mockResponse(t, false, errCortextoolClient) + t.Run("write rule group - error from the mimir client", func(t *testing.T) { + client.mockResponse(t, false, errMimirClient) spec := make(map[string]interface{}) file, err := os.ReadFile("testdata/rules.yaml") require.NoError(t, err) @@ -102,28 +102,55 @@ func TestRules(t *testing.T) { }) } -type FakeCortexTool struct { +type FakeClient struct { hasFile bool expectedError error } -func (f *FakeCortexTool) mockResponse(t *testing.T, hasFile bool, expectedError error) { - f.hasFile = hasFile - f.expectedError = expectedError - t.Cleanup(func() { - f.hasFile = false - f.expectedError = nil - }) -} - -func (f *FakeCortexTool) ExecuteCortexTool(_ ...string) ([]byte, error) { +func (f *FakeClient) ListRules() (map[string][]models.PrometheusRuleGroup, error) { if f.expectedError != nil { return nil, f.expectedError } if f.hasFile { - return os.ReadFile("testdata/list_rules.yaml") + res, err := os.ReadFile("testdata/list_rules.yaml") + if err != nil { + return nil, err + } + + var group map[string][]models.PrometheusRuleGroup + if err := yaml.Unmarshal(res, &group); err != nil { + return nil, err + } + + return group, nil } return nil, nil } + +func (f *FakeClient) CreateRules(_ models.PrometheusRuleGrouping) error { + if f.expectedError != nil { + return f.expectedError + } + + if f.hasFile { + _, err := os.ReadFile("testdata/list_rules.yaml") + if err != nil { + return err + } + + return nil + } + + return nil +} + +func (f *FakeClient) mockResponse(t *testing.T, hasFile bool, expectedError error) { + f.hasFile = hasFile + f.expectedError = expectedError + t.Cleanup(func() { + f.hasFile = false + f.expectedError = nil + }) +} diff --git a/pkg/syntheticmonitoring/httpclient.go b/pkg/syntheticmonitoring/httpclient.go index 4a887113..bb98cf69 100644 --- a/pkg/syntheticmonitoring/httpclient.go +++ b/pkg/syntheticmonitoring/httpclient.go @@ -7,7 +7,7 @@ import ( "time" ) -func NewHttpClient() (*http.Client, error) { +func NewHTTPClient() (*http.Client, error) { timeout := 10 * time.Second if timeoutStr := os.Getenv("GRIZZLY_HTTP_TIMEOUT"); timeoutStr != "" { timeoutSeconds, err := strconv.Atoi(timeoutStr) diff --git a/pkg/syntheticmonitoring/provider.go b/pkg/syntheticmonitoring/provider.go index d425cfff..85cee314 100644 --- a/pkg/syntheticmonitoring/provider.go +++ b/pkg/syntheticmonitoring/provider.go @@ -22,6 +22,9 @@ type ClientProvider interface { // NewProvider instantiates a new Provider. func NewProvider(config *config.SyntheticMonitoringConfig) (*Provider, error) { + if config.URL == "" { + config.URL = "https://synthetic-monitoring-api.grafana.net" + } if config.StackID == 0 { return nil, fmt.Errorf("stack id is not set") } @@ -34,6 +37,7 @@ func NewProvider(config *config.SyntheticMonitoringConfig) (*Provider, error) { if config.Token == "" { return nil, fmt.Errorf("token is not set") } + return &Provider{ config: config, }, nil @@ -67,12 +71,12 @@ func (p *Provider) GetHandlers() []grizzly.Handler { // NewClient creates a new client for synthetic monitoring go client func (p *Provider) Client() (*smapi.Client, error) { - client, err := NewHttpClient() + client, err := NewHTTPClient() if err != nil { return nil, err } - smClient := smapi.NewClient(smBaseURL, "", client) + smClient := smapi.NewClient(p.config.URL, "", client) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/pkg/syntheticmonitoring/synthetic-monitoring-handler.go b/pkg/syntheticmonitoring/synthetic-monitoring-handler.go index d315463a..1996ca21 100644 --- a/pkg/syntheticmonitoring/synthetic-monitoring-handler.go +++ b/pkg/syntheticmonitoring/synthetic-monitoring-handler.go @@ -25,8 +25,6 @@ import ( * them to IDs, having requested an ID<->string mapping from the API. */ -const smBaseURL = "https://synthetic-monitoring-api.grafana.net" - type Probes struct { ByID map[int64]synthetic_monitoring.Probe ByName map[string]synthetic_monitoring.Probe @@ -35,7 +33,6 @@ type Probes struct { // SyntheticMonitoringHandler is a Grizzly Handler for Grafana Synthetic Monitoring type SyntheticMonitoringHandler struct { grizzly.BaseHandler - smProvider Provider } // NewSyntheticMonitoringHandler returns a Grizzly Handler for Grafana Synthetic Monitoring @@ -99,8 +96,8 @@ func (h *SyntheticMonitoringHandler) GetSpecUID(resource grizzly.Resource) (stri } // GetByUID retrieves JSON for a resource from an endpoint, by UID -func (h *SyntheticMonitoringHandler) GetByUID(UID string) (*grizzly.Resource, error) { - return h.getRemoteCheck(UID) +func (h *SyntheticMonitoringHandler) GetByUID(uid string) (*grizzly.Resource, error) { + return h.getRemoteCheck(uid) } // GetRemote retrieves a datasource as a Resource diff --git a/pkg/syntheticmonitoring/synthetic-monitoring_test.go b/pkg/syntheticmonitoring/synthetic-monitoring_test.go index 2b7bc1f1..d45473d1 100644 --- a/pkg/syntheticmonitoring/synthetic-monitoring_test.go +++ b/pkg/syntheticmonitoring/synthetic-monitoring_test.go @@ -11,7 +11,6 @@ import ( ) func TestSyntheticMonitoring(t *testing.T) { - t.Run("Check getUID is functioning correctly", func(t *testing.T) { resource := grizzly.Resource{ Body: map[string]any{