Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,13 @@ COVER_PROFILE ?= cover.out
# Human-readable reports (not used by CI; see kubernetes-sigs/cluster-api `test-cover` pattern).
COVER_OUTPUT_DIR ?= out

.PHONY: verify-grafana-dashboard
verify-grafana-dashboard: kustomize ## Validate Grafana dashboard JSON and PromQL references.
chmod +x hack/verify-grafana-dashboard.sh
./hack/verify-grafana-dashboard.sh

.PHONY: test
test: manifests generate fmt vet setup-envtest ## Run tests.
test: manifests generate fmt vet setup-envtest verify-grafana-dashboard ## Run tests.
KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" \
go test $$(go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -v /e2e) -coverprofile $(COVER_PROFILE)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A Kubernetes operator that provides a declarative API to deploy, manage, and saf

- [Introduction](https://mcp-lifecycle-operator.sigs.k8s.io/introduction/) - Architecture and MCPServer API overview
- [Quickstart Guide](https://mcp-lifecycle-operator.sigs.k8s.io/guides/quickstart/) - Get up and running quickly
- [Metrics](https://mcp-lifecycle-operator.sigs.k8s.io/operating/metrics/) - Prometheus metrics reference
- [Metrics](https://mcp-lifecycle-operator.sigs.k8s.io/operating/metrics/) - Prometheus metrics reference and Grafana dashboard
- [API Reference](https://mcp-lifecycle-operator.sigs.k8s.io/reference/) - Full MCPServer API documentation
- [Complete MCPServer example](./config/samples/mcp_v1alpha1_mcpserver_complete.yaml) - YAML showing all available fields
- [Contributing](https://mcp-lifecycle-operator.sigs.k8s.io/contributing/) - How to contribute to the project
Expand Down
262 changes: 262 additions & 0 deletions config/grafana/dashboard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/*
Copyright 2026 The Kubernetes Authors

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_test

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/prometheus/prometheus/promql/parser"
"gopkg.in/yaml.v3"
)

const dashboardFile = "mcp-lifecycle-operator.json"

// knownMetrics lists metric selectors the dashboard is allowed to reference.
// Keep in sync with internal/controller/metrics.go and controller-runtime defaults.
var knownMetrics = []string{
"mcpserver_condition_info",
"mcpserver_validation_failures_total",
"mcpserver_deployment_failures_total",
"mcpserver_service_failures_total",
"mcpserver_networkpolicy_failures_total",
"mcpserver_reconcile_phase_duration_seconds",
"mcpserver_reconcile_phase_duration_seconds_bucket",
"controller_runtime_reconcile_total",
"controller_runtime_reconcile_errors_total",
"controller_runtime_reconcile_time_seconds_bucket",
"controller_runtime_active_workers",
"process_cpu_seconds_total",
"process_resident_memory_bytes",
"rest_client_requests_total",
}

func TestDashboardJSONIsValid(t *testing.T) {
path := filepath.Join(".", dashboardFile)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read dashboard: %v", err)
}

var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
t.Fatalf("dashboard is not valid JSON: %v", err)
}

for _, key := range []string{"title", "uid", "panels", "tags"} {
if _, ok := root[key]; !ok {
t.Errorf("dashboard missing required field %q", key)
}
}

if root["uid"] != "mcp-lifecycle-operator" {
t.Errorf("expected uid mcp-lifecycle-operator, got %v", root["uid"])
}

if _, ok := root["__inputs"]; ok {
t.Error("dashboard must not use __inputs; use the datasource template variable for provisioning")
}

panels, ok := root["panels"].([]any)
if !ok || len(panels) == 0 {
t.Fatal("dashboard must contain panels")
}
}

func TestDashboardQueriesReferenceKnownMetrics(t *testing.T) {
path := filepath.Join(".", dashboardFile)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read dashboard: %v", err)
}
content := string(data)

exprs := extractExprs(data)
if len(exprs) == 0 {
t.Fatal("no PromQL expressions found in dashboard")
}

required := []string{
"mcpserver_condition_info",
"mcpserver_validation_failures_total",
"mcpserver_deployment_failures_total",
"mcpserver_service_failures_total",
"mcpserver_networkpolicy_failures_total",
"mcpserver_reconcile_phase_duration_seconds",
"controller_runtime_reconcile_total",
"controller_runtime_reconcile_errors_total",
"controller_runtime_reconcile_time_seconds",
}
for _, metric := range required {
if !strings.Contains(content, metric) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks the raw JSON text, so a metric name lingering in a panel title or legend keeps the test green even after its query is deleted. The seen set computed below already holds the selectors actually parsed from every expr - checking required against seen instead closes the gap.

t.Errorf("dashboard missing required metric reference %q", metric)
}
}

unimplemented := []string{
"referenced_resources_count",
"mcpserver_configmap",
"mcpserver_secret",
}
for _, metric := range unimplemented {
if strings.Contains(content, metric) {
t.Errorf("dashboard references unimplemented metric %q", metric)
}
}

seen := map[string]struct{}{}
for _, expr := range exprs {
names, err := metricSelectorsFromExpr(expr)
if err != nil {
t.Fatalf("parse expr %q: %v", expr, err)
}
for _, name := range names {
seen[name] = struct{}{}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

allowed := make(map[string]struct{}, len(knownMetrics))
for _, m := range knownMetrics {
allowed[m] = struct{}{}
}
for name := range seen {
if _, ok := allowed[name]; !ok {
t.Errorf("expression uses unexpected metric selector %q", name)
}
}
}

func TestDashboardKustomizationBuilds(t *testing.T) {
t.Helper()

Comment on lines +147 to +149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove t.Helper() from the top-level test function.

Calling t.Helper() inside a TestXxx function marks the test itself as a helper. If a failure occurs (e.g., t.Fatalf), the testing framework will skip this function in the call stack and report the failure's location as being inside the Go standard library's internal testing.go runner. This makes it difficult to pinpoint the exact line that failed in the test.

♻️ Proposed fix
 func TestDashboardKustomizationBuilds(t *testing.T) {
-	t.Helper()
-
 	kustomize := filepath.Join("..", "..", "bin", "kustomize")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestDashboardKustomizationBuilds(t *testing.T) {
t.Helper()
func TestDashboardKustomizationBuilds(t *testing.T) {
kustomize := filepath.Join("..", "..", "bin", "kustomize")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/grafana/dashboard_test.go` around lines 147 - 149, Remove the
t.Helper() call from the top-level TestDashboardKustomizationBuilds function,
leaving the rest of the test unchanged so failures report the correct test
source location.

kustomize := filepath.Join("..", "..", "bin", "kustomize")
if _, err := os.Stat(kustomize); err != nil {
t.Fatalf("kustomize binary missing at %s: %v", kustomize, err)
}

cmd := exec.Command(kustomize, "build", ".")
cmd.Dir = "."
out, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
t.Fatalf("kustomize build failed: %v\n%s", err, string(exitErr.Stderr))
}
t.Fatalf("kustomize build failed: %v", err)
}

docs := splitYAMLDocuments(string(out))
if len(docs) == 0 {
t.Fatal("kustomize build produced no documents")
}

var found bool
for _, doc := range docs {
var obj map[string]any
if err := yaml.Unmarshal([]byte(doc), &obj); err != nil {
t.Fatalf("parse kustomize output: %v", err)
}
if obj["kind"] != "ConfigMap" {
continue
}
meta, _ := obj["metadata"].(map[string]any)
labels, _ := meta["labels"].(map[string]any)
if labels["grafana_dashboard"] != "1" {
t.Errorf("expected grafana_dashboard=1 label, got %#v", labels)
}
data, _ := obj["data"].(map[string]any)
raw, ok := data["mcp-lifecycle-operator.json"].(string)
if !ok || raw == "" {
t.Fatal("ConfigMap missing mcp-lifecycle-operator.json data")
}
var dash map[string]any
if err := json.Unmarshal([]byte(raw), &dash); err != nil {
t.Fatalf("embedded dashboard is not valid JSON: %v", err)
}
if dash["uid"] != "mcp-lifecycle-operator" {
t.Errorf("embedded dashboard uid = %v, want mcp-lifecycle-operator", dash["uid"])
}
found = true
}
if !found {
t.Fatal("kustomize build did not produce Grafana dashboard ConfigMap")
}
}

func metricSelectorsFromExpr(expr string) ([]string, error) {
parsed, err := parser.ParseExpr(expr)
if err != nil {
return nil, err
}

var names []string
parser.Inspect(parsed, func(node parser.Node, _ []parser.Node) error {
if vs, ok := node.(*parser.VectorSelector); ok && vs.Name != "" {
names = append(names, vs.Name)
}
return nil
})
return names, nil
}

func splitYAMLDocuments(raw string) []string {
var docs []string
for part := range strings.SplitSeq(raw, "\n---") {
part = strings.TrimSpace(part)
if part != "" {
docs = append(docs, part)
}
}
return docs
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func extractExprs(data []byte) []string {
var root any
if err := json.Unmarshal(data, &root); err != nil {
return nil
}
var exprs []string
walkJSON(root, func(key string, value string) {
if key == "expr" {
if strings.TrimSpace(value) != "" {
exprs = append(exprs, value)
}
}
})
return exprs
}

func walkJSON(node any, fn func(key, value string)) {
switch v := node.(type) {
case map[string]any:
for k, child := range v {
switch c := child.(type) {
case string:
fn(k, c)
default:
walkJSON(c, fn)
}
}
case []any:
for _, child := range v {
walkJSON(child, fn)
}
}
}
22 changes: 22 additions & 0 deletions config/grafana/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Optional Grafana dashboard provisioning for environments that use the
# Grafana Helm chart sidecar (label grafana_dashboard: "1").
#
# Apply from a repository checkout:
# kubectl apply -k config/grafana/
#
# Note: the grafana_folder annotation is honored only when the Grafana chart sets
# sidecar.dashboards.folderAnnotation: grafana_folder
# sidecar.dashboards.provider.foldersFromFilesStructure: true
# Without those values, dashboards are still imported but may land in the default folder.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

configMapGenerator:
- name: grafana-dashboard-mcp-lifecycle-operator
files:
- mcp-lifecycle-operator.json
options:
labels:
grafana_dashboard: "1"
annotations:
grafana_folder: MCP Lifecycle Operator
Loading