diff --git a/.github/workflows/dashboard-updater.yml b/.github/workflows/dashboard-updater.yml index e3f1853073..6783cc5c7d 100644 --- a/.github/workflows/dashboard-updater.yml +++ b/.github/workflows/dashboard-updater.yml @@ -24,7 +24,7 @@ jobs: - name: Check for update run: | - ./hco/automation/dashboard-updater/dashboard-updater.sh "./monitoring/dashboards/openshift" "./hco/assets/dashboards" + ./hco/automation/dashboard-updater/dashboard-updater.sh "./monitoring/dashboards/openshift" "./hco/assets/dashboards/grafana" cd hco git add --all if ! git diff HEAD --quiet --exit-code; then diff --git a/assets/dashboards/grafana-dashboard-kubevirt-top-consumers.yaml b/assets/dashboards/grafana/grafana-dashboard-kubevirt-top-consumers.yaml similarity index 100% rename from assets/dashboards/grafana-dashboard-kubevirt-top-consumers.yaml rename to assets/dashboards/grafana/grafana-dashboard-kubevirt-top-consumers.yaml diff --git a/assets/dashboards/perses/README.md b/assets/dashboards/perses/README.md new file mode 100644 index 0000000000..1143579d2e --- /dev/null +++ b/assets/dashboards/perses/README.md @@ -0,0 +1,13 @@ +Place Perses CR manifests here (YAML files) to be applied by the observability controller. + +Expected kinds (apiVersion subject to the Observability Operator installed in the cluster): +- PersesDashboard (e.g. apiVersion: observability.rhobs/v1alpha1) +- PersesDatasource (e.g. apiVersion: observability.rhobs/v1alpha1) + +Manifests will be server-side applied into the operator namespace at runtime. + +Example: memory-load dashboard +- Source: https://github.com/fabiand/perses-dashboards/blob/main/manifests/memory-load.yaml +- Copy the file here as `memory-load.yaml`. The controller will override `metadata.namespace` to the operator namespace. + + diff --git a/controllers/observability/observability_controller.go b/controllers/observability/observability_controller.go index 6b94805c3d..327a173f06 100644 --- a/controllers/observability/observability_controller.go +++ b/controllers/observability/observability_controller.go @@ -50,6 +50,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result errors = append(errors, err) } + // Apply Perses dashboards/datasources shipped with the operator, if any + if err := r.ReconcilePersesResources(ctx); err != nil { + errors = append(errors, err) + } + if len(errors) > 0 { err := fmt.Errorf("reconciliation failed: %v", errors) log.Error(err, "Reconciliation failed") diff --git a/controllers/observability/perses.go b/controllers/observability/perses.go new file mode 100644 index 0000000000..309608ef8a --- /dev/null +++ b/controllers/observability/perses.go @@ -0,0 +1,95 @@ +package observability + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" +) + +// persesAssetsDir is the directory (inside the repo image) containing Perses CR manifests (yaml). +// Each YAML should define a single resource: PersesDashboard or PersesDatasource. +const persesAssetsDir = "assets/dashboards/perses" + +// ReconcilePersesResources applies all Perses resources shipped with the operator under assets/perses. +// It uses server-side apply to create/update resources idempotently without relying on cache reads. +func (r *Reconciler) ReconcilePersesResources(ctx context.Context) error { + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working dir: %w", err) + } + + d := os.DirFS(wd) + // If the directory does not exist, nothing to do. + if _, err := fs.Stat(d, persesAssetsDir); err != nil { + return nil + } + + return fs.WalkDir(d, persesAssetsDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + if ext := filepath.Ext(entry.Name()); ext != ".yml" && ext != ".yaml" { + return nil + } + + file, err := d.Open(path) + if err != nil { + return fmt.Errorf("failed to open %s: %w", path, err) + } + defer file.Close() + + content, err := fs.ReadFile(d, path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + + // Decode YAML → JSON → map + jsonBytes, err := yaml.YAMLToJSON(content) + if err != nil { + return fmt.Errorf("failed to convert yaml to json for %s: %w", path, err) + } + + var objMap map[string]any + if err := json.Unmarshal(jsonBytes, &objMap); err != nil { + return fmt.Errorf("failed to unmarshal json for %s: %w", path, err) + } + + // Enforce namespace to operator's namespace to ensure ownership & GC + // If the manifest specifies a namespace, it will be overridden. + // We only support namespaced resources here. + metadata, _ := objMap["metadata"].(map[string]any) + if metadata == nil { + metadata = map[string]any{} + objMap["metadata"] = metadata + } + metadata["namespace"] = r.namespace + + // Build Unstructured with GVK populated + apiVersion, _ := objMap["apiVersion"].(string) + kind, _ := objMap["kind"].(string) + if apiVersion == "" || kind == "" { + return fmt.Errorf("missing apiVersion/kind in %s", path) + } + + u := &unstructured.Unstructured{Object: objMap} + u.SetGroupVersionKind(schema.FromAPIVersionAndKind(apiVersion, kind)) + + // Apply with SSA so we don't need to read existing objects + if err := r.Patch(ctx, u, client.Apply, client.FieldOwner("hyperconverged-operator"), client.ForceOwnership); err != nil { + return fmt.Errorf("failed to apply %s: %w", path, err) + } + + return nil + }) +}