From fb82bd9b016a09e461fcc527925fa02e348d4b1f Mon Sep 17 00:00:00 2001 From: Simone Tiraboschi Date: Mon, 27 Jul 2026 18:36:53 +0200 Subject: [PATCH] [release-4.22] feat: add feature catalog with generated status and debug endpoint Structured approach to the feature maturity table proposed in https://github.com/openshift-virtualization/virt-platform-autopilot/pull/251 Instead of a manually-maintained table, features are declared in metadata.yaml alongside assets. Shared derivation logic in pkg/assets builds the catalog for both the generator and the debug server: - docs/generated/feature-status.json: structured data for CI/tests - README.md: markdown table injected between sentinel comments - /debug/features: live feature catalog on the debug server Key design decisions: - Coverage validation: every asset must be covered by a feature entry or explicitly listed in excluded_assets, preventing silent gaps. - Soft dependencies: features can declare a requires field listing operators that must be installed for the feature to activate. - Framework maturity: tracked separately (currently TP) and rendered as a note above the feature table; will be removed at GA. - Maturity auto-derivation: install:always assets without explicit maturity are classified as GA; opt-in features need dp or tp. - Opt-in reporting: only user-facing conditions (annotations, feature gates, HCO prerequisites) appear in generated output; internal gates such as topology, hardware detection, and image availability are omitted. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Simone Tiraboschi --- .github/workflows/verify-generated.yml | 3 + .gitignore | 1 + Makefile | 10 ++ README.md | 21 +++ assets/active/metadata.yaml | 87 +++++++++ cmd/feature-status-gen/main.go | 231 ++++++++++++++++++++++++ cmd/main.go | 2 +- docs/ARCHITECTURE.md | 72 +++++++- docs/adding-assets.md | 19 ++ docs/debug-endpoints.md | 56 ++++++ docs/generated/feature-status.json | 101 +++++++++++ pkg/assets/features.go | 235 +++++++++++++++++++++++++ pkg/assets/features_test.go | 151 ++++++++++++++++ pkg/assets/registry.go | 64 ++++++- pkg/assets/registry_test.go | 57 ++++++ pkg/debug/handlers.go | 22 +++ pkg/debug/handlers_test.go | 61 +++++++ 17 files changed, 1190 insertions(+), 3 deletions(-) create mode 100644 cmd/feature-status-gen/main.go create mode 100644 docs/generated/feature-status.json create mode 100644 pkg/assets/features.go create mode 100644 pkg/assets/features_test.go diff --git a/.github/workflows/verify-generated.yml b/.github/workflows/verify-generated.yml index a7304ecf..ae49d6ba 100644 --- a/.github/workflows/verify-generated.yml +++ b/.github/workflows/verify-generated.yml @@ -28,6 +28,9 @@ jobs: - name: Verify RBAC is up-to-date run: make verify-rbac + - name: Verify feature status is up-to-date + run: make verify-feature-status + # Save PR number so the comment workflow (running with write access in the # base-repo context) can look it up — artifact is the only safe channel # across the pull_request / workflow_run boundary for fork PRs. diff --git a/.gitignore b/.gitignore index 9150b45d..96e27c33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Binaries for programs and plugins +/feature-status-gen *.exe *.exe~ *.dll diff --git a/Makefile b/Makefile index 63578cce..3f468216 100644 --- a/Makefile +++ b/Makefile @@ -137,6 +137,16 @@ verify-rbac: ## Verify RBAC matches generated (for CI) @rm -f /tmp/generated-rbac.yaml @echo "✓ RBAC is up-to-date" +.PHONY: generate-feature-status +generate-feature-status: ## Generate feature status table and JSON from metadata + @echo "Generating feature status from metadata..." + @go run cmd/feature-status-gen/main.go + +.PHONY: verify-feature-status +verify-feature-status: ## Verify feature status matches generated (for CI) + @echo "Verifying feature status is up-to-date..." + @go run cmd/feature-status-gen/main.go --dry-run + .PHONY: update-crds update-crds: ## Update CRD collection from upstream hack/update-crds.sh diff --git a/README.md b/README.md index da28952a..7a354068 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,27 @@ The autopilot applies opinionated best practices and manages your platform autom **Install once. Run VMs. Customize via GitOps when needed.** +## Features + + +> **Note:** The autopilot framework is currently **DP** and requires `platform.kubevirt.io/autopilot=true` on the HCO CR. Feature maturity levels below are relative to an enabled autopilot. + +| Feature | Maturity | Install | Opt-in | Requires | Recommended | +|---------|----------|---------|--------|----------|-------------| +| In-Flight Operations | TP | always | - | - | - | +| Kubelet Performance | TP | always | - | - | - | +| Load-Aware Descheduler | TP | always | - | Kube Descheduler Operator | - | +| Observability | TP | always | - | Cluster Observability Operator | - | +| SWAP | TP | always | - | - | - | +| CPU Manager | DP | opt-in | `featureGate:CPUManager` | - | - | +| Kernel Samepage Merging (KSM) zero pages only | DP | opt-in | `platform.kubevirt.io/enable-ksm-zero-only=true, hcoUnconfigured:spec.virtualization.ksmConfiguration` | - | - | +| KubeVirt Metrics Exporter | DP | opt-in | `platform.kubevirt.io/enable-metrics-exporter=true` | - | Cluster Observability Operator | +| MTV Operator | DP | opt-in | `platform.kubevirt.io/enable-mtv=true` | - | - | +| MetalLB Operator | DP | opt-in | `platform.kubevirt.io/enable-metallb=true` | - | - | +| PCI Passthrough | DP | opt-in | `platform.kubevirt.io/openshift=true, platform.kubevirt.io/enable-pci-passthrough=true` | - | - | +| Transparent Huge Pages (THP) Tuning | DP | opt-in | `platform.kubevirt.io/enable-thp-tuning=true` | - | - | + + ## Quick Start ### Prerequisites diff --git a/assets/active/metadata.yaml b/assets/active/metadata.yaml index bc9464c7..e7ee6f89 100644 --- a/assets/active/metadata.yaml +++ b/assets/active/metadata.yaml @@ -66,6 +66,9 @@ assets: value: "true" - type: hardware-detection detector: pciDevicesPresent + - type: annotation + key: platform.kubevirt.io/enable-pci-passthrough + value: "true" - name: psi-enable group: descheduler-loadaware @@ -738,3 +741,87 @@ assets: # - node-maintenance-operator (operators/node-maintenance.yaml.tpl) # - fence-agents-operator (operators/fence-agents.yaml.tpl) # - usb-passthrough (machine-config/05-usb-passthrough.yaml.tpl) + +# Framework-level maturity. Remove opt_in once the autopilot graduates to GA. +framework: + maturity: dp + opt_in: platform.kubevirt.io/autopilot=true + +# Feature catalog: maps user-facing features to assets/groups. +# - maturity is required only for opt-in features ("dp" or "tp") +# - features whose assets are all install: always are auto-classified as GA +# - opt-in conditions are derived from the referenced assets (no duplication) +# - every asset must be covered by a feature entry or listed in excluded_assets + +# Assets intentionally excluded from feature tracking (e.g. internal-only, +# not yet user-facing, or managed externally). +excluded_assets: + - hco-golden-config + +features: + - name: SWAP + description: OpenShift worker node swap support + maturity: tp + assets: [swap-enable] + + - name: Kubelet Performance + description: Optimized kubelet settings for virtualization workloads + maturity: tp + assets: [kubelet-perf-settings] + + - name: Observability + description: Prometheus metrics, alerting rules, and Perses dashboards in the OpenShift console + maturity: tp + assets: [metrics-service, metrics-servicemonitor, prometheus-alerts, monitoring-ui-plugin, autopilot-dashboard] + requires: + - Cluster Observability Operator + + - name: Load-Aware Descheduler + description: Load-aware VM balancing based on CPU/memory utilization and pressure stall metrics + maturity: tp + assets: [descheduler-loadaware, psi-enable, psi-enable-master] + requires: + - Kube Descheduler Operator + + - name: PCI Passthrough + description: GPU/PCI device passthrough via VFIO + maturity: dp + assets: [pci-passthrough] + + - name: CPU Manager + description: Dedicated CPU pinning for guaranteed QoS workloads + maturity: dp + assets: [kubelet-cpu-manager] + + - name: MTV Operator + description: Migration Toolkit for Virtualization + maturity: dp + assets: [mtv-operator] + + - name: MetalLB Operator + description: Bare-metal load balancer for services + maturity: dp + assets: [metallb-operator] + + - name: KubeVirt Metrics Exporter + description: Per-node VM storage I/O latency collection via QMP, QGA, and eBPF, and detailed KVM and memory statistics + maturity: dp + groups: [metrics-exporter] + recommended: + - Cluster Observability Operator + + - name: In-Flight Operations + description: OperationRuleSet-based coordination for safe concurrent operations + maturity: tp + groups: [inflightoperations] + + - name: Kernel Samepage Merging (KSM) zero pages only + description: Node-level KSM tuning that enables zero-pages-only deduplication with adaptive scan rate + maturity: dp + groups: [ksm-zero-only] + + - name: Transparent Huge Pages (THP) Tuning + description: Node-level THP tuning that sets madvise mode and khugepaged scan rate for KVM guest memory + maturity: dp + groups: [thp-tuning] + diff --git a/cmd/feature-status-gen/main.go b/cmd/feature-status-gen/main.go new file mode 100644 index 00000000..ba64f46f --- /dev/null +++ b/cmd/feature-status-gen/main.go @@ -0,0 +1,231 @@ +/* +Copyright 2026 The KubeVirt 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. +*/ + +// feature-status-gen reads the features catalog from assets/active/metadata.yaml, +// derives maturity levels, and generates: +// - docs/generated/feature-status.json (structured data for CI) +// - README.md section between sentinel comments (human-readable table) +// +// Run via 'make generate-feature-status'. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "sigs.k8s.io/yaml" + + "github.com/kubevirt/virt-platform-autopilot/pkg/assets" +) + +const ( + metadataPath = "assets/active/metadata.yaml" + jsonOutput = "docs/generated/feature-status.json" + readmePath = "README.md" + beginMarker = "" + endMarker = "" +) + +func main() { + dryRun := flag.Bool("dry-run", false, "Write to /tmp and diff against committed files instead of writing in place") + flag.Parse() + + catalog, err := loadCatalog() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading metadata: %v\n", err) + os.Exit(1) + } + + if err := assets.ValidateFeatureCoverage(catalog); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + output, err := assets.DeriveFeatureCatalog(catalog) + if err != nil { + fmt.Fprintf(os.Stderr, "Error deriving feature statuses: %v\n", err) + os.Exit(1) + } + + jsonData, err := json.MarshalIndent(output, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshalling JSON: %v\n", err) + os.Exit(1) + } + jsonData = append(jsonData, '\n') + + table := renderTable(output.Framework, output.Features) + + if *dryRun { + failed := false + + tmpJSON := "/tmp/generated-feature-status.json" + if err := os.WriteFile(tmpJSON, jsonData, 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", tmpJSON, err) + os.Exit(1) + } + defer os.Remove(tmpJSON) + + if diffErr := runDiff(jsonOutput, tmpJSON); diffErr != nil { + failed = true + } + + tmpReadme := "/tmp/generated-readme-features.md" + if err := os.WriteFile(tmpReadme, []byte(table), 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", tmpReadme, err) + os.Exit(1) + } + defer os.Remove(tmpReadme) + + currentSection, err := extractSentinelSection(readmePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error extracting README section: %v\n", err) + failed = true + } else { + tmpCurrent := "/tmp/current-readme-features.md" + if err := os.WriteFile(tmpCurrent, []byte(currentSection), 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", tmpCurrent, err) + os.Exit(1) + } + defer os.Remove(tmpCurrent) + + if diffErr := runDiff(tmpCurrent, tmpReadme); diffErr != nil { + failed = true + } + } + + if failed { + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Feature status is out of date.") + fmt.Fprintln(os.Stderr, " 1. Run: make generate-feature-status") + fmt.Fprintln(os.Stderr, " 2. Commit the updated files") + os.Exit(1) + } + + fmt.Println("Feature status is up-to-date.") + return + } + + if err := os.MkdirAll(filepath.Dir(jsonOutput), 0755); err != nil { + fmt.Fprintf(os.Stderr, "Error creating directory: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(jsonOutput, jsonData, 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", jsonOutput, err) + os.Exit(1) + } + fmt.Printf("Wrote %s\n", jsonOutput) + + if err := injectIntoReadme(readmePath, table); err != nil { + fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", readmePath, err) + os.Exit(1) + } + fmt.Printf("Updated %s\n", readmePath) +} + +func loadCatalog() (*assets.AssetCatalog, error) { + data, err := os.ReadFile(metadataPath) + if err != nil { + return nil, err + } + catalog := &assets.AssetCatalog{} + if err := yaml.Unmarshal(data, catalog); err != nil { + return nil, err + } + return catalog, nil +} + +func renderTable(fw assets.FrameworkStatus, statuses []assets.FeatureStatus) string { + var b strings.Builder + + if fw.Maturity != "GA" && fw.OptIn != nil { + fmt.Fprintf(&b, "> **Note:** The autopilot framework is currently **%s** and requires `%s` on the HCO CR. Feature maturity levels below are relative to an enabled autopilot.\n\n", fw.Maturity, *fw.OptIn) + } + + b.WriteString("| Feature | Maturity | Install | Opt-in | Requires | Recommended |\n") + b.WriteString("|---------|----------|---------|--------|----------|-------------|\n") + + for _, s := range statuses { + optIn := "-" + if s.OptIn != nil { + optIn = "`" + *s.OptIn + "`" + } + requires := "-" + if len(s.Requires) > 0 { + requires = strings.Join(s.Requires, ", ") + } + recommended := "-" + if len(s.Recommended) > 0 { + recommended = strings.Join(s.Recommended, ", ") + } + fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s |\n", s.Name, s.Maturity, s.Install, optIn, requires, recommended) + } + + return b.String() +} + +func extractSentinelSection(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + content := string(data) + + beginIdx := strings.Index(content, beginMarker) + endIdx := strings.Index(content, endMarker) + if beginIdx < 0 || endIdx < 0 || endIdx <= beginIdx { + return "", fmt.Errorf("sentinel markers not found in %s", path) + } + + section := content[beginIdx+len(beginMarker) : endIdx] + section = strings.TrimPrefix(section, "\n") + section = strings.TrimSuffix(section, "\n") + return section + "\n", nil +} + +func injectIntoReadme(path, table string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + content := string(data) + + beginIdx := strings.Index(content, beginMarker) + endIdx := strings.Index(content, endMarker) + if beginIdx < 0 || endIdx < 0 || endIdx <= beginIdx { + return fmt.Errorf("sentinel markers not found in %s", path) + } + + var b strings.Builder + b.WriteString(content[:beginIdx+len(beginMarker)]) + b.WriteString("\n") + b.WriteString(table) + b.WriteString(content[endIdx:]) + + return os.WriteFile(path, []byte(b.String()), 0644) +} + +func runDiff(fileA, fileB string) error { + cmd := exec.Command("diff", "-u", fileA, fileB) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/cmd/main.go b/cmd/main.go index a82274bd..da7285ae 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -130,7 +130,7 @@ func newRunCommand() *cobra.Command { cmd.Flags().DurationVar(&crdValidationTimeout, "crd-validation-timeout", 10*time.Second, "Timeout for validating that required CRDs exist at startup.") cmd.Flags().BoolVar(&enableDebugServer, "enable-debug-server", true, - "Enable debug HTTP server with /debug/render and /debug/exclusions endpoints.") + "Enable debug HTTP server with /debug/render, /debug/exclusions, and /debug/features endpoints.") cmd.Flags().BoolVar(&development, "development", true, "Enable development mode logging.") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bd7ff7a1..2c8b83cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -270,6 +270,7 @@ kubectl port-forward -n openshift-cnv deployment/virt-platform-autopilot 8081:80 - `/debug/render` - Render all assets based on current HCO state - `/debug/render/{asset}` - Render specific asset by name - `/debug/exclusions` - List excluded/filtered assets with reasons +- `/debug/features` - Feature catalog with maturity, install mode, and opt-in conditions - `/debug/tombstones` - List tombstones (resources marked for deletion) - `/debug/health` - Health check status @@ -463,7 +464,8 @@ Kubernetes events are emitted for significant state changes: virt-platform-autopilot/ ├── cmd/ │ ├── main.go # Manager entrypoint -│ └── rbac-gen/ # RBAC generation tool +│ ├── rbac-gen/ # RBAC generation tool +│ └── feature-status-gen/ # Feature status table/JSON generator ├── pkg/ │ ├── controller/ # Main reconciler │ ├── engine/ # Rendering, patching, drift detection @@ -483,6 +485,7 @@ virt-platform-autopilot/ │ └── tombstones/ # Obsolete resources for deletion ├── config/ # Kubernetes manifests for deployment └── docs/ # Documentation + └── generated/ # Auto-generated artifacts (feature-status.json) ``` ## Asset Management @@ -537,6 +540,57 @@ assets: - `reconcile_order`: Processing order within a phase (lower = earlier) - `conditions`: Activation conditions (annotations, hardware detection, feature gates) — all must be satisfied (AND logic) +### Feature Catalog + +The metadata catalog also contains a `features:` section that maps user-facing features to the underlying assets and groups. This is the source of truth for the feature status table in `README.md` and the structured JSON artifact at `docs/generated/feature-status.json`. + +```yaml +features: + - name: SWAP + description: OpenShift worker node swap support + assets: [swap-enable] + + - name: Logging + description: Integrated logging stack with LokiStack and ClusterLogForwarder + maturity: tp + groups: [logging, audit-logging] + + - name: Metrics Exporter + description: Per-node VM storage I/O latency collection + maturity: dp + groups: [metrics-exporter] +``` + +**Feature fields:** + +- `name`: Human-readable feature name (displayed in the README table) +- `description`: One-line description of the feature +- `maturity`: Explicit maturity level — `dp` (Development Preview) or `tp` (Technology Preview). Only required for opt-in features; omit for GA features (auto-derived) +- `assets`: List of individual asset names this feature comprises +- `groups`: List of asset group names this feature comprises +- `requires`: List of hard dependencies (e.g. operators) required for the feature to function +- `recommended`: Optional integrations that improve UX/visibility but are not required for core function (for example dashboards/UI plugins) + +**Coverage validation:** The generator errors if any asset in the `assets:` section is not referenced by a feature entry (by name or group). Assets intentionally excluded from feature tracking (e.g. internal-only or not yet user-facing) must be listed in `excluded_assets:`: + +```yaml +excluded_assets: + - hco-golden-config +``` + +**Maturity derivation rules:** + +| `maturity` field | Referenced assets' `install` | Derived maturity | +|---|---|---| +| `"dp"` | any | DP | +| `"tp"` | any | TP | +| omitted | all `always` | GA | +| omitted | any `opt-in` | DP (fallback) | + +The opt-in annotation is automatically derived from the referenced assets' `conditions` — no duplication needed in the feature entry. + +Run `make generate-feature-status` after modifying the `features:` section to regenerate the README table and JSON artifact. CI validates consistency via `make verify-feature-status`. + ### Soft Dependencies The autopilot gracefully handles missing runtime dependencies without raising errors or blocking other assets. @@ -603,6 +657,22 @@ This scans `assets/active/` for resource types and generates: - ClusterRole with required permissions - RoleBindings for service account +### Feature Status Generation + +The feature status table in `README.md` and the structured JSON artifact at `docs/generated/feature-status.json` are auto-generated from the `features:` section in `metadata.yaml`: + +```bash +# After adding/modifying features in metadata.yaml, regenerate +make generate-feature-status +``` + +This reads the feature catalog, resolves each feature's assets (by name or group), derives the maturity level and opt-in conditions, and carries dependency metadata (`requires` + `recommended`) into generated output: + +- **`docs/generated/feature-status.json`** — structured data for CI/test consumption (e.g. verifying that all opt-in features have working annotation paths) +- **`README.md`** — markdown table injected between `` / `` sentinel comments + +CI validates both outputs via `make verify-feature-status`, which diffs the committed files against a fresh generation. The generator lives at `cmd/feature-status-gen/main.go`. + ### Testing ```bash diff --git a/docs/adding-assets.md b/docs/adding-assets.md index caa03913..9f02082d 100644 --- a/docs/adding-assets.md +++ b/docs/adding-assets.md @@ -318,6 +318,25 @@ The `assets/active/metadata.yaml` catalog defines all managed assets. **conditions**: Array of conditions that must ALL be true for asset to be applied. +### Feature Metadata (`features:` section) + +The same `assets/active/metadata.yaml` file also defines user-facing feature entries in `features:`. +When documenting dependencies there, use: + +- `requires`: Hard dependencies required for the feature to function at all. +- `recommended`: Optional integrations that improve UX/visibility (for example dashboards or UI plugins) but are not required for core behavior. + +Example: + +```yaml +- name: KubeVirt Metrics Exporter + description: Per-node VM storage I/O latency collection + maturity: dp + groups: [metrics-exporter] + recommended: + - Cluster Observability Operator +``` + ### Condition Types #### Annotation Condition diff --git a/docs/debug-endpoints.md b/docs/debug-endpoints.md index 9d0679af..8699fc45 100644 --- a/docs/debug-endpoints.md +++ b/docs/debug-endpoints.md @@ -169,6 +169,62 @@ curl http://localhost:8081/debug/exclusions?format=json | jq '.[] | select(.reas resource: "KubeDescheduler/cluster" ``` +#### `/debug/features` + +Returns the derived feature catalog: framework maturity/opt-in gate and the sorted feature list with maturity, install mode, opt-in conditions, hard dependencies (`requires`), and optional integrations (`recommended`). + +**Query Parameters:** +- `format` - Output format: `yaml` (default) or `json` + +**Examples:** +```bash +# List all features (YAML) +curl http://localhost:8081/debug/features + +# Pretty-print JSON (same shape as docs/generated/feature-status.json) +curl http://localhost:8081/debug/features?format=json | jq '.' +``` + +**Response:** +```json +{ + "framework": { + "maturity": "TP", + "opt_in": "platform.kubevirt.io/autopilot=true" + }, + "features": [ + { + "name": "SWAP", + "description": "OpenShift worker node swap support", + "maturity": "GA", + "install": "always", + "opt_in": null + }, + { + "name": "Logging", + "description": "Integrated logging stack with LokiStack and ClusterLogForwarder", + "maturity": "DP", + "install": "opt-in", + "opt_in": "platform.kubevirt.io/enable-logging=true, platform.kubevirt.io/enable-audit-logging=true", + "requires": [ + "Loki Operator", + "Red Hat OpenShift Logging Operator" + ] + }, + { + "name": "KubeVirt Metrics Exporter", + "description": "Per-node VM storage I/O latency collection via QMP, QGA, and eBPF, and detailed KVM and memory statistics", + "maturity": "DP", + "install": "opt-in", + "opt_in": "platform.kubevirt.io/enable-metrics-exporter=true", + "recommended": [ + "Cluster Observability Operator" + ] + } + ] +} +``` + #### `/debug/tombstones` Lists all tombstones (obsolete resources to be deleted). diff --git a/docs/generated/feature-status.json b/docs/generated/feature-status.json new file mode 100644 index 00000000..f8fa7cc0 --- /dev/null +++ b/docs/generated/feature-status.json @@ -0,0 +1,101 @@ +{ + "framework": { + "maturity": "DP", + "opt_in": "platform.kubevirt.io/autopilot=true" + }, + "features": [ + { + "name": "In-Flight Operations", + "description": "OperationRuleSet-based coordination for safe concurrent operations", + "maturity": "TP", + "install": "always", + "opt_in": null + }, + { + "name": "Kubelet Performance", + "description": "Optimized kubelet settings for virtualization workloads", + "maturity": "TP", + "install": "always", + "opt_in": null + }, + { + "name": "Load-Aware Descheduler", + "description": "Load-aware VM balancing based on CPU/memory utilization and pressure stall metrics", + "maturity": "TP", + "install": "always", + "opt_in": null, + "requires": [ + "Kube Descheduler Operator" + ] + }, + { + "name": "Observability", + "description": "Prometheus metrics, alerting rules, and Perses dashboards in the OpenShift console", + "maturity": "TP", + "install": "always", + "opt_in": null, + "requires": [ + "Cluster Observability Operator" + ] + }, + { + "name": "SWAP", + "description": "OpenShift worker node swap support", + "maturity": "TP", + "install": "always", + "opt_in": null + }, + { + "name": "CPU Manager", + "description": "Dedicated CPU pinning for guaranteed QoS workloads", + "maturity": "DP", + "install": "opt-in", + "opt_in": "featureGate:CPUManager" + }, + { + "name": "Kernel Samepage Merging (KSM) zero pages only", + "description": "Node-level KSM tuning that enables zero-pages-only deduplication with adaptive scan rate", + "maturity": "DP", + "install": "opt-in", + "opt_in": "platform.kubevirt.io/enable-ksm-zero-only=true, hcoUnconfigured:spec.virtualization.ksmConfiguration" + }, + { + "name": "KubeVirt Metrics Exporter", + "description": "Per-node VM storage I/O latency collection via QMP, QGA, and eBPF, and detailed KVM and memory statistics", + "maturity": "DP", + "install": "opt-in", + "opt_in": "platform.kubevirt.io/enable-metrics-exporter=true", + "recommended": [ + "Cluster Observability Operator" + ] + }, + { + "name": "MTV Operator", + "description": "Migration Toolkit for Virtualization", + "maturity": "DP", + "install": "opt-in", + "opt_in": "platform.kubevirt.io/enable-mtv=true" + }, + { + "name": "MetalLB Operator", + "description": "Bare-metal load balancer for services", + "maturity": "DP", + "install": "opt-in", + "opt_in": "platform.kubevirt.io/enable-metallb=true" + }, + { + "name": "PCI Passthrough", + "description": "GPU/PCI device passthrough via VFIO", + "maturity": "DP", + "install": "opt-in", + "opt_in": "platform.kubevirt.io/openshift=true, platform.kubevirt.io/enable-pci-passthrough=true" + }, + { + "name": "Transparent Huge Pages (THP) Tuning", + "description": "Node-level THP tuning that sets madvise mode and khugepaged scan rate for KVM guest memory", + "maturity": "DP", + "install": "opt-in", + "opt_in": "platform.kubevirt.io/enable-thp-tuning=true" + } + ] +} diff --git a/pkg/assets/features.go b/pkg/assets/features.go new file mode 100644 index 00000000..53e81907 --- /dev/null +++ b/pkg/assets/features.go @@ -0,0 +1,235 @@ +/* +Copyright 2026 The KubeVirt 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 assets + +import ( + "fmt" + "sort" + "strings" +) + +// FrameworkStatus describes the maturity and opt-in gate of the autopilot framework. +type FrameworkStatus struct { + Maturity string `json:"maturity"` + OptIn *string `json:"opt_in"` +} + +// FeatureStatus is the derived user-facing status of a catalog feature. +type FeatureStatus struct { + Name string `json:"name"` + Description string `json:"description"` + Maturity string `json:"maturity"` + Install string `json:"install"` + OptIn *string `json:"opt_in"` + Requires []string `json:"requires,omitempty"` + Recommended []string `json:"recommended,omitempty"` +} + +// FeatureCatalog is the structured feature list exposed by generators and debug endpoints. +type FeatureCatalog struct { + Framework FrameworkStatus `json:"framework"` + Features []FeatureStatus `json:"features"` +} + +// FeatureCatalog returns the derived feature catalog from the registry metadata. +func (r *Registry) FeatureCatalog() (*FeatureCatalog, error) { + return DeriveFeatureCatalog(r.catalog) +} + +// ValidateFeatureCoverage ensures every asset is covered by a feature or excluded_assets. +func ValidateFeatureCoverage(catalog *AssetCatalog) error { + covered := make(map[string]bool) + + for _, excl := range catalog.ExcludedAssets { + covered[excl] = true + } + + for _, feature := range catalog.Features { + for _, name := range feature.Assets { + covered[name] = true + } + for _, group := range feature.Groups { + for _, a := range catalog.Assets { + if a.Group == group { + covered[a.Name] = true + } + } + } + } + + var uncovered []string + for _, a := range catalog.Assets { + if !covered[a.Name] { + uncovered = append(uncovered, a.Name) + } + } + + if len(uncovered) > 0 { + return fmt.Errorf("assets not covered by any feature or excluded_assets: %s\n"+ + " Add them to a features entry or to excluded_assets in metadata.yaml", + strings.Join(uncovered, ", ")) + } + return nil +} + +// DeriveFeatureCatalog builds the framework and feature status list from metadata. +func DeriveFeatureCatalog(catalog *AssetCatalog) (*FeatureCatalog, error) { + statuses, err := deriveFeatureStatuses(catalog) + if err != nil { + return nil, err + } + return &FeatureCatalog{ + Framework: deriveFrameworkStatus(catalog), + Features: statuses, + }, nil +} + +func deriveFeatureStatuses(catalog *AssetCatalog) ([]FeatureStatus, error) { + assetByName := make(map[string]*AssetMetadata, len(catalog.Assets)) + for i := range catalog.Assets { + assetByName[catalog.Assets[i].Name] = &catalog.Assets[i] + } + + var statuses []FeatureStatus + for _, feature := range catalog.Features { + resolved := resolveFeatureAssets(feature, catalog.Assets, assetByName) + if len(resolved) == 0 { + return nil, fmt.Errorf("feature %q resolves to zero assets", feature.Name) + } + + statuses = append(statuses, FeatureStatus{ + Name: feature.Name, + Description: feature.Description, + Maturity: deriveFeatureMaturity(feature, resolved), + Install: deriveFeatureInstall(resolved), + OptIn: deriveFeatureOptIn(resolved), + Requires: feature.Requires, + Recommended: feature.Recommended, + }) + } + sortFeatureStatuses(statuses) + return statuses, nil +} + +func resolveFeatureAssets(feature FeatureMetadata, allAssets []AssetMetadata, byName map[string]*AssetMetadata) []*AssetMetadata { + var resolved []*AssetMetadata + seen := make(map[string]bool) + + for _, name := range feature.Assets { + if a, ok := byName[name]; ok && !seen[name] { + resolved = append(resolved, a) + seen[name] = true + } + } + + for _, group := range feature.Groups { + for i := range allAssets { + if allAssets[i].Group == group && !seen[allAssets[i].Name] { + resolved = append(resolved, &allAssets[i]) + seen[allAssets[i].Name] = true + } + } + } + + return resolved +} + +func deriveFeatureMaturity(feature FeatureMetadata, resolved []*AssetMetadata) string { + if feature.Maturity != "" { + return strings.ToUpper(feature.Maturity) + } + for _, a := range resolved { + if a.Install == InstallModeOptIn { + return "DP" + } + } + return "GA" +} + +func deriveFeatureInstall(resolved []*AssetMetadata) string { + for _, a := range resolved { + if a.Install == InstallModeOptIn { + return "opt-in" + } + } + return "always" +} + +func deriveFeatureOptIn(resolved []*AssetMetadata) *string { + var conditions []string + seen := make(map[string]bool) + + for _, a := range resolved { + for _, c := range a.Conditions { + // Skip internal activation gates that are not user-facing opt-in requirements. + switch c.Type { + case ConditionTypeTopology, + ConditionTypeHardwareDetection, + ConditionTypeImage: + continue + } + entry := FormatCondition(c) + if entry != "" && !seen[entry] { + conditions = append(conditions, entry) + seen[entry] = true + } + } + } + + if len(conditions) == 0 { + return nil + } + result := strings.Join(conditions, ", ") + return &result +} + +func deriveFrameworkStatus(catalog *AssetCatalog) FrameworkStatus { + maturity := "GA" + if catalog.Framework.Maturity != "" { + maturity = strings.ToUpper(catalog.Framework.Maturity) + } + var optIn *string + if catalog.Framework.OptIn != "" { + s := catalog.Framework.OptIn + optIn = &s + } + return FrameworkStatus{Maturity: maturity, OptIn: optIn} +} + +func maturityRank(maturity string) int { + switch strings.ToUpper(maturity) { + case "GA": + return 0 + case "TP": + return 1 + case "DP": + return 2 + default: + return 3 + } +} + +func sortFeatureStatuses(statuses []FeatureStatus) { + sort.Slice(statuses, func(i, j int) bool { + rankI := maturityRank(statuses[i].Maturity) + rankJ := maturityRank(statuses[j].Maturity) + if rankI != rankJ { + return rankI < rankJ + } + return statuses[i].Name < statuses[j].Name + }) +} diff --git a/pkg/assets/features_test.go b/pkg/assets/features_test.go new file mode 100644 index 00000000..82e94816 --- /dev/null +++ b/pkg/assets/features_test.go @@ -0,0 +1,151 @@ +/* +Copyright 2026 The KubeVirt 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 assets + +import ( + "testing" +) + +func TestDeriveFeatureOptInIncludesUserFacingConditionTypes(t *testing.T) { + resolved := []*AssetMetadata{ + { + Name: "pci-passthrough", + Conditions: []AssetCondition{ + {Type: ConditionTypeAnnotation, Key: "platform.kubevirt.io/openshift", Value: "true"}, + {Type: ConditionTypeHardwareDetection, Detector: "pciDevicesPresent"}, + }, + }, + { + Name: "ksm-zero-only-master", + Conditions: []AssetCondition{ + {Type: ConditionTypeAnnotation, Key: "platform.kubevirt.io/enable-ksm-zero-only", Value: "true"}, + {Type: ConditionTypeHCOFieldUnconfigured, Path: "spec.virtualization.ksmConfiguration"}, + {Type: ConditionTypeTopology, Field: "hasSchedulableMasters"}, + }, + }, + } + + got := deriveFeatureOptIn(resolved) + if got == nil { + t.Fatal("deriveFeatureOptIn() = nil, want conditions") + } + + want := "platform.kubevirt.io/openshift=true, platform.kubevirt.io/enable-ksm-zero-only=true, hcoUnconfigured:spec.virtualization.ksmConfiguration" + if *got != want { + t.Errorf("deriveFeatureOptIn() = %q, want %q", *got, want) + } +} + +func TestDeriveFeatureOptInSkipsInternalConditionTypes(t *testing.T) { + resolved := []*AssetMetadata{ + { + Name: "psi-enable-master", + Conditions: []AssetCondition{ + {Type: ConditionTypeTopology, Field: "hasSchedulableMasters"}, + }, + }, + { + Name: "metrics-exporter", + Conditions: []AssetCondition{ + {Type: ConditionTypeImage, Key: "kubevirt-metrics-exporter"}, + }, + }, + } + + if got := deriveFeatureOptIn(resolved); got != nil { + t.Errorf("deriveFeatureOptIn() = %q, want nil", *got) + } +} + +func TestSortFeatureStatuses(t *testing.T) { + statuses := []FeatureStatus{ + {Name: "Zebra", Maturity: "DP"}, + {Name: "Beta", Maturity: "GA"}, + {Name: "Alpha", Maturity: "GA"}, + {Name: "Charlie", Maturity: "TP"}, + } + + sortFeatureStatuses(statuses) + + want := []string{"Alpha", "Beta", "Charlie", "Zebra"} + for i, name := range want { + if statuses[i].Name != name { + t.Fatalf("statuses[%d].Name = %q, want %q", i, statuses[i].Name, name) + } + } +} + +func TestDeriveFeatureStatusesIncludesRecommended(t *testing.T) { + catalog := &AssetCatalog{ + Assets: []AssetMetadata{ + {Name: "metrics-exporter", Install: InstallModeOptIn}, + }, + Features: []FeatureMetadata{ + { + Name: "KubeVirt Metrics Exporter", + Description: "Per-node metrics exporter", + Groups: []string{}, + Assets: []string{"metrics-exporter"}, + Recommended: []string{"Cluster Observability Operator"}, + }, + }, + } + + statuses, err := deriveFeatureStatuses(catalog) + if err != nil { + t.Fatalf("deriveFeatureStatuses() error = %v", err) + } + if len(statuses) != 1 { + t.Fatalf("deriveFeatureStatuses() returned %d statuses, want 1", len(statuses)) + } + if len(statuses[0].Recommended) != 1 || statuses[0].Recommended[0] != "Cluster Observability Operator" { + t.Fatalf("status.Recommended = %v, want [Cluster Observability Operator]", statuses[0].Recommended) + } +} + +func TestRegistryFeatureCatalog(t *testing.T) { + loader := NewLoader() + registry, err := NewRegistry(loader) + if err != nil { + t.Fatalf("NewRegistry() error = %v", err) + } + + catalog, err := registry.FeatureCatalog() + if err != nil { + t.Fatalf("FeatureCatalog() error = %v", err) + } + + if catalog.Framework.Maturity == "" { + t.Fatal("framework maturity is empty") + } + if len(catalog.Features) == 0 { + t.Fatal("expected non-empty feature list") + } + + for i := 1; i < len(catalog.Features); i++ { + prev := catalog.Features[i-1] + curr := catalog.Features[i] + prevRank := maturityRank(prev.Maturity) + currRank := maturityRank(curr.Maturity) + if prevRank > currRank { + t.Fatalf("features not sorted by maturity: %q before %q", prev.Name, curr.Name) + } + if prevRank == currRank && prev.Name > curr.Name { + t.Fatalf("features not sorted by name within maturity: %q before %q", prev.Name, curr.Name) + } + } +} diff --git a/pkg/assets/registry.go b/pkg/assets/registry.go index 6b7456b2..4de02fc7 100644 --- a/pkg/assets/registry.go +++ b/pkg/assets/registry.go @@ -58,6 +58,48 @@ type AssetCondition struct { Field string `json:"field,omitempty"` // For topology (TopologyContext.AsMap key) } +// FormatCondition returns a stable, human-readable representation of an asset +// condition for documentation and feature-status output. +func FormatCondition(c AssetCondition) string { + switch c.Type { + case ConditionTypeAnnotation: + if c.Key == "" { + return "" + } + return c.Key + "=" + c.Value + case ConditionTypeFeatureGate: + if c.Value == "" { + return "" + } + return "featureGate:" + c.Value + case ConditionTypeHardwareDetection: + if c.Detector == "" { + return "" + } + return "hardware:" + c.Detector + case ConditionTypeImage: + if c.Key == "" { + return "" + } + return "image:" + c.Key + case ConditionTypeHCOFieldUnconfigured: + if c.Path == "" { + return "" + } + return "hcoUnconfigured:" + c.Path + case ConditionTypeTopology: + if c.Field == "" { + return "" + } + if c.Value != "" && c.Value != "true" { + return "topology:" + c.Field + "=" + c.Value + } + return "topology:" + c.Field + default: + return "" + } +} + // AssetMetadata defines the metadata for a managed asset type AssetMetadata struct { Name string `json:"name"` @@ -74,9 +116,29 @@ type AssetMetadata struct { RequiredCRD string `json:"-"` // Derived from template at load time; empty for core API types } +// FeatureMetadata defines a user-facing feature composed of one or more assets +type FeatureMetadata struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Maturity string `json:"maturity,omitempty"` // "dp" or "tp"; empty = auto-derive from install mode + Assets []string `json:"assets,omitempty"` // individual asset names + Groups []string `json:"groups,omitempty"` // asset group names + Requires []string `json:"requires,omitempty"` // hard dependencies (feature can't function without them) + Recommended []string `json:"recommended,omitempty"` // optional integrations that improve UX/visibility +} + +// FrameworkMetadata defines the maturity of the autopilot framework itself +type FrameworkMetadata struct { + Maturity string `json:"maturity,omitempty"` // "dp", "tp", or empty (GA) + OptIn string `json:"opt_in,omitempty"` // global opt-in annotation; empty when GA +} + // AssetCatalog contains all asset metadata type AssetCatalog struct { - Assets []AssetMetadata `json:"assets"` + Assets []AssetMetadata `json:"assets"` + Framework FrameworkMetadata `json:"framework"` + ExcludedAssets []string `json:"excluded_assets,omitempty"` + Features []FeatureMetadata `json:"features"` } // Registry manages the asset catalog and provides querying capabilities diff --git a/pkg/assets/registry_test.go b/pkg/assets/registry_test.go index 27a4a5b8..b3556038 100644 --- a/pkg/assets/registry_test.go +++ b/pkg/assets/registry_test.go @@ -828,3 +828,60 @@ func arrayItemSchema(schema *apiextensionsv1.JSONSchemaProps) *apiextensionsv1.J } return schema.Items.Schema } + +func TestFormatCondition(t *testing.T) { + tests := []struct { + name string + condition AssetCondition + want string + }{ + { + name: "annotation", + condition: AssetCondition{Type: ConditionTypeAnnotation, Key: "platform.kubevirt.io/enable-mtv", Value: "true"}, + want: "platform.kubevirt.io/enable-mtv=true", + }, + { + name: "feature gate", + condition: AssetCondition{Type: ConditionTypeFeatureGate, Value: "CPUManager"}, + want: "featureGate:CPUManager", + }, + { + name: "hardware detection", + condition: AssetCondition{Type: ConditionTypeHardwareDetection, Detector: "pciDevicesPresent"}, + want: "hardware:pciDevicesPresent", + }, + { + name: "image", + condition: AssetCondition{Type: ConditionTypeImage, Key: "kubevirt-metrics-exporter"}, + want: "image:kubevirt-metrics-exporter", + }, + { + name: "hco field unconfigured", + condition: AssetCondition{Type: ConditionTypeHCOFieldUnconfigured, Path: "spec.virtualization.ksmConfiguration"}, + want: "hcoUnconfigured:spec.virtualization.ksmConfiguration", + }, + { + name: "topology", + condition: AssetCondition{Type: ConditionTypeTopology, Field: "hasSchedulableMasters"}, + want: "topology:hasSchedulableMasters", + }, + { + name: "topology with false value", + condition: AssetCondition{Type: ConditionTypeTopology, Field: "hasSchedulableMasters", Value: "false"}, + want: "topology:hasSchedulableMasters=false", + }, + { + name: "empty annotation key", + condition: AssetCondition{Type: ConditionTypeAnnotation, Value: "true"}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FormatCondition(tt.condition); got != tt.want { + t.Errorf("FormatCondition() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/debug/handlers.go b/pkg/debug/handlers.go index 2975c098..18dc8f9c 100644 --- a/pkg/debug/handlers.go +++ b/pkg/debug/handlers.go @@ -59,6 +59,7 @@ func (s *Server) InstallHandlers(mux *http.ServeMux) { mux.HandleFunc("/debug/render", s.handleRender) mux.HandleFunc("/debug/render/", s.handleRenderAsset) // Trailing slash for path params mux.HandleFunc("/debug/exclusions", s.handleExclusions) + mux.HandleFunc("/debug/features", s.handleFeatures) mux.HandleFunc("/debug/tombstones", s.handleTombstones) mux.HandleFunc("/debug/health", s.handleHealth) } @@ -271,6 +272,27 @@ func (s *Server) handleExclusions(w http.ResponseWriter, r *http.Request) { s.writeResponse(w, exclusions, format) } +// handleFeatures returns the derived feature catalog (maturity, install mode, opt-in conditions). +func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + format := r.URL.Query().Get("format") + if format == "" { + format = "yaml" + } + + catalog, err := s.registry.FeatureCatalog() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to derive feature catalog: %v", err), http.StatusInternalServerError) + return + } + + s.writeResponse(w, catalog, format) +} + // TombstoneInfo represents information about tombstones type TombstoneInfo struct { Kind string `json:"kind" yaml:"kind"` diff --git a/pkg/debug/handlers_test.go b/pkg/debug/handlers_test.go index 656e17f6..f4fd015a 100644 --- a/pkg/debug/handlers_test.go +++ b/pkg/debug/handlers_test.go @@ -248,6 +248,66 @@ func TestHandleExclusions(t *testing.T) { } } +func TestHandleFeatures(t *testing.T) { + loader := assets.NewLoader() + registry, err := assets.NewRegistry(loader) + require.NoError(t, err) + + server := NewServer(nil, loader, registry) + + tests := []struct { + name string + queryParams string + expectedStatus int + checkResponse func(t *testing.T, body string) + }{ + { + name: "yaml format", + queryParams: "", + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, body string) { + var catalog assets.FeatureCatalog + err := yaml.Unmarshal([]byte(body), &catalog) + assert.NoError(t, err) + assert.NotEmpty(t, catalog.Features) + assert.NotEmpty(t, catalog.Framework.Maturity) + }, + }, + { + name: "json format", + queryParams: "?format=json", + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, body string) { + var catalog assets.FeatureCatalog + err := json.Unmarshal([]byte(body), &catalog) + assert.NoError(t, err) + assert.NotEmpty(t, catalog.Features) + + names := make([]string, 0, len(catalog.Features)) + for _, feature := range catalog.Features { + names = append(names, feature.Name) + } + assert.Contains(t, names, "Observability") + assert.Contains(t, names, "SWAP") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/debug/features"+tt.queryParams, nil) + w := httptest.NewRecorder() + + server.handleFeatures(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, w.Body.String()) + } + }) + } +} + func TestHandleTombstones(t *testing.T) { loader := assets.NewLoader() registry, err := assets.NewRegistry(loader) @@ -325,6 +385,7 @@ func TestMethodNotAllowed(t *testing.T) { endpoints := []string{ "/debug/render", "/debug/exclusions", + "/debug/features", "/debug/tombstones", }