From e2def6982df36e295578b9560d9b8d44bbfc0327 Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Boll Date: Thu, 2 Apr 2026 12:22:08 +0000 Subject: [PATCH] Add verification for grafana dashboards --- tools/grafanactl/cmd/verify/cmd.go | 96 +++++++++++++++++++ tools/grafanactl/cmd/verify/options.go | 96 +++++++++++++++++++ .../grafanactl/internal/grafana/validator.go | 64 +++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 tools/grafanactl/cmd/verify/cmd.go create mode 100644 tools/grafanactl/cmd/verify/options.go create mode 100644 tools/grafanactl/internal/grafana/validator.go diff --git a/tools/grafanactl/cmd/verify/cmd.go b/tools/grafanactl/cmd/verify/cmd.go new file mode 100644 index 0000000..0b6362d --- /dev/null +++ b/tools/grafanactl/cmd/verify/cmd.go @@ -0,0 +1,96 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package verify + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/go-logr/logr" + "github.com/spf13/cobra" + + "github.com/Azure/ARO-Tools/tools/grafanactl/internal/grafana" +) + +const dashboardsGroupID = "dashboards" + +func NewVerifyCommand(group string) (*cobra.Command, error) { + opts := DefaultVerifyDashboardsOptions() + + verifyCmd := &cobra.Command{ + Use: "verify", + Short: "Verify Grafana resources", + Long: "Verify Grafana resources for correctness", + GroupID: group, + } + + verifyCmd.AddGroup(&cobra.Group{ + ID: dashboardsGroupID, + Title: "Verify Commands:", + }) + + verifyDashboardsCmd := &cobra.Command{ + Use: "dashboards", + Short: "Verify Grafana dashboards", + Long: "Verify Grafana dashboards present in the configured paths pass validation", + GroupID: dashboardsGroupID, + RunE: func(cmd *cobra.Command, args []string) error { + return opts.Run(cmd.Context()) + }, + } + + if err := BindVerifyDashboardsOptions(opts, verifyDashboardsCmd); err != nil { + return nil, err + } + + verifyCmd.AddCommand(verifyDashboardsCmd) + + return verifyCmd, nil +} + +func (opts *RawVerifyDashboardsOptions) Run(ctx context.Context) error { + validated, err := opts.Validate(ctx) + if err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + completed, err := validated.Complete(ctx) + if err != nil { + return fmt.Errorf("completion failed: %w", err) + } + + return completed.Run(ctx) +} + +func (o *CompletedVerifyDashboardsOptions) Run(ctx context.Context) error { + logger := logr.FromContextOrDiscard(ctx) + + logger.Info("Starting dashboard verification") + + configDir := filepath.Dir(o.ConfigFilePath) + + validationErrors, _, err := grafana.ValidateAllDashboards(ctx, o.Config, configDir) + if err != nil { + return fmt.Errorf("verification failed: %w", err) + } + + if len(validationErrors) > 0 { + return fmt.Errorf("verification found errors in %d dashboards", len(validationErrors)) + } + + logger.Info("Dashboard verification completed successfully") + return nil +} diff --git a/tools/grafanactl/cmd/verify/options.go b/tools/grafanactl/cmd/verify/options.go new file mode 100644 index 0000000..077ef63 --- /dev/null +++ b/tools/grafanactl/cmd/verify/options.go @@ -0,0 +1,96 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package verify + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/Azure/ARO-Tools/tools/grafanactl/config" +) + +// RawVerifyDashboardsOptions represents the initial, unvalidated configuration for verify operations. +type RawVerifyDashboardsOptions struct { + ConfigFilePath string +} + +// validatedVerifyDashboardsOptions is a private struct that enforces the options validation pattern. +type validatedVerifyDashboardsOptions struct { + *RawVerifyDashboardsOptions +} + +// ValidatedVerifyDashboardsOptions represents verify configuration that has passed validation. +type ValidatedVerifyDashboardsOptions struct { + // Embed a private pointer that cannot be instantiated outside of this package + *validatedVerifyDashboardsOptions +} + +// CompletedVerifyDashboardsOptions represents the final, fully validated and initialized configuration +// for verify operations. +type CompletedVerifyDashboardsOptions struct { + *validatedVerifyDashboardsOptions + Config *config.ObservabilityConfig +} + +// DefaultVerifyDashboardsOptions returns a new RawVerifyDashboardsOptions with default values +func DefaultVerifyDashboardsOptions() *RawVerifyDashboardsOptions { + return &RawVerifyDashboardsOptions{} +} + +// BindVerifyDashboardsOptions binds command-line flags to the options +func BindVerifyDashboardsOptions(opts *RawVerifyDashboardsOptions, cmd *cobra.Command) error { + flags := cmd.Flags() + flags.StringVar(&opts.ConfigFilePath, "config-file", "", "Path to config file with Grafana dashboard references (absolute or relative path, required)") + + _ = cmd.MarkFlagRequired("config-file") + return nil +} + +// Validate performs validation on the raw options +func (o *RawVerifyDashboardsOptions) Validate(ctx context.Context) (*ValidatedVerifyDashboardsOptions, error) { + absPath, err := filepath.Abs(o.ConfigFilePath) + if err != nil { + return nil, fmt.Errorf("failed to resolve config file path: %w", err) + } + + if _, err := os.Stat(absPath); err != nil { + return nil, fmt.Errorf("config file not found: %w", err) + } + + o.ConfigFilePath = absPath + + return &ValidatedVerifyDashboardsOptions{ + validatedVerifyDashboardsOptions: &validatedVerifyDashboardsOptions{ + RawVerifyDashboardsOptions: o, + }, + }, nil +} + +// Complete performs final initialization to create fully usable verify options. +func (o *ValidatedVerifyDashboardsOptions) Complete(ctx context.Context) (*CompletedVerifyDashboardsOptions, error) { + cfg, err := config.LoadFromFile(o.ConfigFilePath) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + + return &CompletedVerifyDashboardsOptions{ + validatedVerifyDashboardsOptions: o.validatedVerifyDashboardsOptions, + Config: cfg, + }, nil +} diff --git a/tools/grafanactl/internal/grafana/validator.go b/tools/grafanactl/internal/grafana/validator.go new file mode 100644 index 0000000..4620278 --- /dev/null +++ b/tools/grafanactl/internal/grafana/validator.go @@ -0,0 +1,64 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grafana + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/go-logr/logr" + + "github.com/Azure/ARO-Tools/tools/grafanactl/config" +) + +// ValidateAllDashboards reads all configured dashboards from disk and validates them. +// It returns validation errors and warnings. Warnings are logged but do not cause failure. +func ValidateAllDashboards(ctx context.Context, cfg *config.ObservabilityConfig, configDir string) (allErrors []ValidationIssue, allWarnings []ValidationIssue, err error) { + logger := logr.FromContextOrDiscard(ctx) + + for _, folder := range cfg.GrafanaDashboards.DashboardFolders { + fullPath := filepath.Join(configDir, folder.Path) + logger.Info("Validating dashboards", "folder", folder.Name, "path", fullPath) + + entries, err := os.ReadDir(fullPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to read directory %q: %w", fullPath, err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + filePath := filepath.Join(fullPath, entry.Name()) + dashboard, err := readDashboardFile(filePath) + if err != nil { + logger.Error(err, "Failed to read dashboard file", "file", filePath) + continue + } + + errors, warnings := validateDashboard(dashboard, folder.Path) + allErrors = append(allErrors, errors...) + allWarnings = append(allWarnings, warnings...) + } + } + + reportValidationIssues(ctx, allErrors, allWarnings) + + return allErrors, allWarnings, nil +}