Compare commits

..

2 Commits

Author SHA1 Message Date
c6c53b1117 wip: created basic struct for separation natsjetstream config from nats 2026-02-11 11:09:11 -05:00
0b9499bc97 doc: adr 019 2026-02-06 15:32:50 -05:00
153 changed files with 3222 additions and 21537 deletions

3
.gitignore vendored
View File

@@ -26,6 +26,3 @@ Cargo.lock
*.pdb
.harmony_generated
# Useful to create ignore folders for temp files and notes
ignore

2586
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
resolver = "2"
members = [
"private_repos/*",
"examples/*",
"harmony",
"harmony_types",
"harmony_macros",
@@ -18,7 +19,7 @@ members = [
"adr/agent_discovery/mdns",
"brocade",
"harmony_agent",
"harmony_agent/deploy", "harmony_node_readiness", "harmony-k8s",
"harmony_agent/deploy",
]
[workspace.package]
@@ -37,8 +38,6 @@ tokio = { version = "1.40", features = [
"macros",
"rt-multi-thread",
] }
tokio-retry = "0.3.0"
tokio-util = "0.7.15"
cidr = { features = ["serde"], version = "0.2" }
russh = "0.45"
russh-keys = "0.45"
@@ -53,7 +52,6 @@ kube = { version = "1.1.0", features = [
"jsonpatch",
] }
k8s-openapi = { version = "0.25", features = ["v1_30"] }
# TODO replace with https://github.com/bourumir-wyngs/serde-saphyr as serde_yaml is deprecated https://github.com/sebastienrousseau/serde_yml
serde_yaml = "0.9"
serde-value = "0.7"
http = "1.2"

View File

@@ -1,6 +1,4 @@
# Harmony
Open-source infrastructure orchestration that treats your platform like first-class code.
# Harmony : Open-source infrastructure orchestration that treats your platform like first-class code
In other words, Harmony is a **next-generation platform engineering framework**.
@@ -22,7 +20,9 @@ All in **one strongly-typed Rust codebase**.
From a **developer laptop** to a **global production cluster**, a single **source of truth** drives the **full software lifecycle.**
## The Harmony Philosophy
---
## 1 · The Harmony Philosophy
Infrastructure is essential, but it shouldnt be your core business. Harmony is built on three guiding principles that make modern platforms reliable, repeatable, and easy to reason about.
@@ -34,18 +34,9 @@ Infrastructure is essential, but it shouldnt be your core business. Harmony i
These principles surface as simple, ergonomic Rust APIs that let teams focus on their product while trusting the platform underneath.
## Where to Start
---
We have a comprehensive set of documentation right here in the repository.
| I want to... | Start Here |
| ----------------- | ------------------------------------------------------------------ |
| Get Started | [Getting Started Guide](./docs/guides/getting-started.md) |
| See an Example | [Use Case: Deploy a Rust Web App](./docs/use-cases/rust-webapp.md) |
| Explore | [Documentation Hub](./docs/README.md) |
| See Core Concepts | [Core Concepts Explained](./docs/concepts.md) |
## Quick Look: Deploy a Rust Webapp
## 2 · Quick Start
The snippet below spins up a complete **production-grade Rust + Leptos Webapp** with monitoring. Swap it for your own scores to deploy anything from microservices to machine-learning pipelines.
@@ -103,33 +94,63 @@ async fn main() {
}
```
To run this:
Run it:
- Clone the repository: `git clone https://git.nationtech.io/nationtech/harmony`
- Install dependencies: `cargo build --release`
- Run the example: `cargo run --example try_rust_webapp`
```bash
cargo run
```
## Documentation
Harmony analyses the code, shows an execution plan in a TUI, and applies it once you confirm. Same code, same binary—every environment.
All documentation is in the `/docs` directory.
---
- [Documentation Hub](./docs/README.md): The main entry point for all documentation.
- [Core Concepts](./docs/concepts.md): A detailed look at Score, Topology, Capability, Inventory, and Interpret.
- [Component Catalogs](./docs/catalogs/README.md): Discover all available Scores, Topologies, and Capabilities.
- [Developer Guide](./docs/guides/developer-guide.md): Learn how to write your own Scores and Topologies.
## 3 · Core Concepts
## Architectural Decision Records
| Term | One-liner |
| ---------------- | ---------------------------------------------------------------------------------------------------- |
| **Score<T>** | Declarative description of the desired state (e.g., `LAMPScore`). |
| **Interpret<T>** | Imperative logic that realises a `Score` on a specific environment. |
| **Topology** | An environment (local k3d, AWS, bare-metal) exposing verified _Capabilities_ (Kubernetes, DNS, …). |
| **Maestro** | Orchestrator that compiles Scores + Topology, ensuring all capabilities line up **at compile-time**. |
| **Inventory** | Optional catalogue of physical assets for bare-metal and edge deployments. |
- [ADR-001 · Why Rust](adr/001-rust.md)
- [ADR-003 · Infrastructure Abstractions](adr/003-infrastructure-abstractions.md)
- [ADR-006 · Secret Management](adr/006-secret-management.md)
- [ADR-011 · Multi-Tenant Cluster](adr/011-multi-tenant-cluster.md)
A visual overview is in the diagram below.
## Contribute
[Harmony Core Architecture](docs/diagrams/Harmony_Core_Architecture.drawio.svg)
Discussions and roadmap live in [Issues](https://git.nationtech.io/nationtech/harmony/-/issues). PRs, ideas, and feedback are welcome!
---
## License
## 4 · Install
Prerequisites:
- Rust
- Docker (if you deploy locally)
- `kubectl` / `helm` for Kubernetes-based topologies
```bash
git clone https://git.nationtech.io/nationtech/harmony
cd harmony
cargo build --release # builds the CLI, TUI and libraries
```
---
## 5 · Learning More
- **Architectural Decision Records** dive into the rationale
- [ADR-001 · Why Rust](adr/001-rust.md)
- [ADR-003 · Infrastructure Abstractions](adr/003-infrastructure-abstractions.md)
- [ADR-006 · Secret Management](adr/006-secret-management.md)
- [ADR-011 · Multi-Tenant Cluster](adr/011-multi-tenant-cluster.md)
- **Extending Harmony** write new Scores / Interprets, add hardware like OPNsense firewalls, or embed Harmony in your own tooling (`/docs`).
- **Community** discussions and roadmap live in [GitLab issues](https://git.nationtech.io/nationtech/harmony/-/issues). PRs, ideas, and feedback are welcome!
---
## 6 · License
Harmony is released under the **GNU AGPL v3**.

View File

@@ -0,0 +1,86 @@
Initial Date: 2025-02-06
## Status
Proposed
## Context
The Harmony Agent requires a persistent connection to the NATS Supercluster to perform Key-Value (KV) operations (Read/Write/Watch).
Service Requirements: The agent must authenticate with sufficient privileges to manage KV buckets and interact with the JetStream API.
Infrastructure: NATS is deployed as a multi-site Supercluster. Authentication must be consistent across sites to allow for agent failover and data replication.
https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_intro
Technical Constraint: In NATS, JetStream functionality is not global by default; it must be explicitly enabled and capped at the Account level to allow KV bucket creation and persistence.
## Issues
1. The "System Account" Trap
The Hole: Using the system account for the Harmony Agent.
The Risk: The NATS System Account is for server heartbeat and monitoring. It cannot (and should not) own JetStream KV buckets.
2. Multi-Site Authorization Sync
The Hole: Defining users in local nats.conf files via Helm.
The Risk: If an agent at Site-2 fails over to Site-3, but Site-3s local configuration doesn't have the testUser credentials, the agent will be locked out during an outage.
3. KV Replication Factor
The Hole: Not specifying the Replicas count for the KV bucket.
The Risk: If you create a KV bucket with the default (1 replica), it only exists at the site where it was created. If that site goes down, the data is lost despite having a Supercluster.
4. Subject-Level Permissions
The Hole: Only granting TEST.* permissions.
The Risk: NATS KV uses internal subjects (e.g., $KV.<bucket_name>.>). Without access to these, the agent will get an "Authorization Violation" even if it's logged in.
## Proposed Solution
To enable reliable, secure communication between the Harmony Agent and the NATS Supercluster, we will implement Account-isolated JetStream using NKey Authentication (or mTLS).
1. Dedicated Account Architecture
We will move away from the "Global/Default" account. A dedicated HARMONY account will be defined identically across all sites in the Supercluster. This ensures that the metadata for the KV bucket can replicate across the gateways.
System Account: Reserved for NATS internal health and Supercluster routing.
Harmony Account: Dedicated to Harmony Agent data, with JetStream explicitly enabled.
2. Authentication: Use harmony secret store mounted into nats container
Take advantage of currently implemented solution
3. JetStream & KV Configuration
To ensure the KV bucket is available across the Supercluster, the following configuration must be applied:
Replication Factor (R=3): KV buckets will be created with a replication factor of 3 to ensure data persists across Site-1, Site-2, and Site-3.
Permissions: The agent will be granted scoped access to:
$KV.HARMONY.> (Data operations)
$JS.API.CONSUMER.> and $JS.API.STREAM.> (Management operations)
## Consequence of Decision
Pros
Resilience: Agents can fail over to any site in the Supercluster and find their credentials and data.
Security: By using a dedicated account, the Harmony Agent cannot see or interfere with NATS system traffic.
Scalability: We can add Site-4 or Site-5 simply by copying the HARMONY account definition.
Cons / Risks
Configuration Drift: If one site's ConfigMap is updated without the others, authentication will fail during a site failover.
Complexity: Requires a "Management" step to ensure the account exists on all NATS instances before the agent attempts to connect.

View File

@@ -1,65 +0,0 @@
# Architecture Decision Record: Network Bonding Configuration via External Automation
Initial Author: Jean-Gabriel Gill-Couture & Sylvain Tremblay
Initial Date: 2026-02-13
Last Updated Date: 2026-02-13
## Status
Accepted
## Context
We need to configure LACP bonds on 10GbE interfaces across all worker nodes in the OpenShift cluster. A significant challenge is that interface names (e.g., `enp1s0f0` vs `ens1f0`) vary across different hardware nodes.
The standard OpenShift mechanism (MachineConfig) applies identical configurations to all nodes in a MachineConfigPool. Since the interface names differ, a single static MachineConfig cannot target specific physical devices across the entire cluster without complex workarounds.
## Decision
We will use the existing "Harmony" automation tool to generate and apply host-specific NetworkManager configuration files directly to the nodes.
1. Harmony will generate the specific `.nmconnection` files for the bond and slaves based on its inventory of interface names.
2. Files will be pushed to `/etc/NetworkManager/system-connections/` on each node.
3. Configuration will be applied via `nmcli` reload or a node reboot.
## Rationale
* **Inventory Awareness:** Harmony already possesses the specific interface mapping data for each host.
* **Persistence:** Fedora CoreOS/SCOS allows writing to `/etc`, and these files persist across reboots and OS upgrades (rpm-ostree updates).
* **Avoids Complexity:** This approach avoids the operational overhead of creating unique MachineConfigPools for every single host or hardware variant.
* **Safety:** Unlike wildcard matching, this ensures explicit interface selection, preventing accidental bonding of reserved interfaces (e.g., future separation of Ceph storage traffic).
## Consequences
**Pros:**
* Precise, per-host configuration without polluting the Kubernetes API with hundreds of MachineConfigs.
* Standard Linux networking behavior; easy to debug locally.
* Prevents accidental interface capture (unlike wildcards).
**Cons:**
* **Loss of Declarative K8s State:** The network config is not managed by the Machine Config Operator (MCO).
* **Node Replacement Friction:** Newly provisioned nodes (replacements) will boot with default config. Harmony must be run against new nodes manually or via a hook before they can fully join the cluster workload.
## Alternatives considered
1. **Wildcard Matching in NetworkManager (e.g., `interface-name=enp*`):**
* *Pros:* Single MachineConfig for the whole cluster.
* *Cons:* Rejected because it is too broad. It risks capturing interfaces intended for other purposes (e.g., splitting storage and cluster networks later).
2. **"Kitchen Sink" Configuration:**
* *Pros:* Single file listing every possible interface name as a slave.
* *Cons:* "Dirty" configuration; results in many inactive connections on every host; brittle if new naming schemes appear.
3. **Per-Host MachineConfig:**
* *Pros:* Fully declarative within OpenShift.
* *Cons:* Requires a unique `MachineConfigPool` per host, which is an anti-pattern and unmaintainable at scale.
4. **On-boot Generation Script:**
* *Pros:* Dynamic detection.
* *Cons:* Increases boot complexity; harder to debug if the script fails during startup.
## Additional Notes
While `/etc` is writable and persistent on CoreOS, this configuration falls outside the "Day 1" Ignition process. Operational runbooks must be updated to ensure Harmony runs on any node replacement events.

View File

@@ -1,7 +1,7 @@
use std::net::{IpAddr, Ipv4Addr};
use brocade::{BrocadeOptions, ssh};
use harmony_secret::{Secret, SecretManager};
use harmony_secret::Secret;
use harmony_types::switch::PortLocation;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -21,15 +21,17 @@ async fn main() {
// let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 4, 11)); // brocade @ st
let switch_addresses = vec![ip];
let config = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
.await
.unwrap();
// let config = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
// .await
// .unwrap();
let brocade = brocade::init(
&switch_addresses,
&config.username,
&config.password,
&BrocadeOptions {
// &config.username,
// &config.password,
"admin",
"password",
BrocadeOptions {
dry_run: true,
ssh: ssh::SshOptions {
port: 2222,

View File

@@ -1,7 +1,8 @@
use super::BrocadeClient;
use crate::{
BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceInfo, MacAddressEntry,
PortChannelId, PortOperatingMode, parse_brocade_mac_address, shell::BrocadeShell,
PortChannelId, PortOperatingMode, SecurityLevel, parse_brocade_mac_address,
shell::BrocadeShell,
};
use async_trait::async_trait;

View File

@@ -144,7 +144,7 @@ pub async fn init(
ip_addresses: &[IpAddr],
username: &str,
password: &str,
options: &BrocadeOptions,
options: BrocadeOptions,
) -> Result<Box<dyn BrocadeClient + Send + Sync>, Error> {
let shell = BrocadeShell::init(ip_addresses, username, password, options).await?;

View File

@@ -8,7 +8,7 @@ use regex::Regex;
use crate::{
BrocadeClient, BrocadeInfo, Error, ExecutionMode, InterSwitchLink, InterfaceInfo,
InterfaceStatus, InterfaceType, MacAddressEntry, PortChannelId, PortOperatingMode,
parse_brocade_mac_address, shell::BrocadeShell,
SecurityLevel, parse_brocade_mac_address, shell::BrocadeShell,
};
#[derive(Debug)]

View File

@@ -28,7 +28,7 @@ impl BrocadeShell {
ip_addresses: &[IpAddr],
username: &str,
password: &str,
options: &BrocadeOptions,
options: BrocadeOptions,
) -> Result<Self, Error> {
let ip = ip_addresses
.first()

View File

@@ -70,7 +70,7 @@ pub async fn try_init_client(
username: &str,
password: &str,
ip: &std::net::IpAddr,
base_options: &BrocadeOptions,
base_options: BrocadeOptions,
) -> Result<BrocadeOptions, Error> {
let mut default = SshOptions::default();
default.port = base_options.ssh.port;

View File

@@ -1,33 +1 @@
# Harmony Documentation Hub
Welcome to the Harmony documentation. This is the main entry point for learning everything from core concepts to building your own Score, Topologies, and Capabilities.
## 1. Getting Started
If you're new to Harmony, start here:
- [**Getting Started Guide**](./guides/getting-started.md): A step-by-step tutorial that takes you from an empty project to deploying your first application.
- [**Core Concepts**](./concepts.md): A high-level overview of the key concepts in Harmony: `Score`, `Topology`, `Capability`, `Inventory`, `Interpret`, ...
## 2. Use Cases & Examples
See how to use Harmony to solve real-world problems.
- [**OKD on Bare Metal**](./use-cases/okd-on-bare-metal.md): A detailed walkthrough of bootstrapping a high-availability OKD cluster from physical hardware.
- [**Deploy a Rust Web App**](./use-cases/deploy-rust-webapp.md): A quick guide to deploying a monitored, containerized web application to a Kubernetes cluster.
## 3. Component Catalogs
Discover existing, reusable components you can use in your Harmony projects.
- [**Scores Catalog**](./catalogs/scores.md): A categorized list of all available `Scores` (the "what").
- [**Topologies Catalog**](./catalogs/topologies.md): A list of all available `Topologies` (the "where").
- [**Capabilities Catalog**](./catalogs/capabilities.md): A list of all available `Capabilities` (the "how").
## 4. Developer Guides
Ready to build your own components? These guides show you how.
- [**Writing a Score**](./guides/writing-a-score.md): Learn how to create your own `Score` and `Interpret` logic to define a new desired state.
- [**Writing a Topology**](./guides/writing-a-topology.md): Learn how to model a new environment (like AWS, GCP, or custom hardware) as a `Topology`.
- [**Adding Capabilities**](./guides/adding-capabilities.md): See how to add a `Capability` to your custom `Topology`.
Not much here yet, see the `adr` folder for now. More to come in time!

View File

@@ -1,7 +0,0 @@
# Component Catalogs
This section is the "dictionary" for Harmony. It lists all the reusable components available out-of-the-box.
- [**Scores Catalog**](./scores.md): Discover all available `Scores` (the "what").
- [**Topologies Catalog**](./topologies.md): A list of all available `Topologies` (the "where").
- [**Capabilities Catalog**](./capabilities.md): A list of all available `Capabilities` (the "how").

View File

@@ -1,40 +0,0 @@
# Capabilities Catalog
A `Capability` is a specific feature or API that a `Topology` offers. `Interpret` logic uses these capabilities to execute a `Score`.
This list is primarily for developers **writing new Topologies or Scores**. As a user, you just need to know that the `Topology` you pick (like `K8sAnywhereTopology`) provides the capabilities your `Scores` (like `ApplicationScore`) need.
<!--toc:start-->
- [Capabilities Catalog](#capabilities-catalog)
- [Kubernetes & Application](#kubernetes-application)
- [Monitoring & Observability](#monitoring-observability)
- [Networking (Core Services)](#networking-core-services)
- [Networking (Hardware & Host)](#networking-hardware-host)
<!--toc:end-->
## Kubernetes & Application
- **K8sClient**: Provides an authenticated client to interact with a Kubernetes API (create/read/update/delete resources).
- **HelmCommand**: Provides the ability to execute Helm commands (install, upgrade, template).
- **TenantManager**: Provides methods for managing tenants in a multi-tenant cluster.
- **Ingress**: Provides an interface for managing ingress controllers and resources.
## Monitoring & Observability
- **Grafana**: Provides an API for configuring Grafana (datasources, dashboards).
- **Monitoring**: A general capability for configuring monitoring (e.g., creating Prometheus rules).
## Networking (Core Services)
- **DnsServer**: Provides an interface for creating and managing DNS records.
- **LoadBalancer**: Provides an interface for configuring a load balancer (e.g., OPNsense, MetalLB).
- **DhcpServer**: Provides an interface for managing DHCP leases and host bindings.
- **TftpServer**: Provides an interface for managing files on a TFTP server (e.g., iPXE boot files).
## Networking (Hardware & Host)
- **Router**: Provides an interface for configuring routing rules, typically on a firewall like OPNsense.
- **Switch**: Provides an interface for configuring a physical network switch (e.g., managing VLANs and port channels).
- **NetworkManager**: Provides an interface for configuring host-level networking (e.g., creating bonds and bridges on a node).

View File

@@ -1,102 +0,0 @@
# Scores Catalog
A `Score` is a declarative description of a desired state. Find the Score you need and add it to your `harmony!` block's `scores` array.
<!--toc:start-->
- [Scores Catalog](#scores-catalog)
- [Application Deployment](#application-deployment)
- [OKD / Kubernetes Cluster Setup](#okd-kubernetes-cluster-setup)
- [Cluster Services & Management](#cluster-services-management)
- [Monitoring & Alerting](#monitoring-alerting)
- [Infrastructure & Networking (Bare Metal)](#infrastructure-networking-bare-metal)
- [Infrastructure & Networking (Cluster)](#infrastructure-networking-cluster)
- [Tenant Management](#tenant-management)
- [Utility](#utility)
<!--toc:end-->
## Application Deployment
Scores for deploying and managing end-user applications.
- **ApplicationScore**: The primary score for deploying a web application. Describes the application, its framework, and the features it requires (e.g., monitoring, CI/CD).
- **HelmChartScore**: Deploys a generic Helm chart to a Kubernetes cluster.
- **ArgoHelmScore**: Deploys an application using an ArgoCD Helm chart.
- **LAMPScore**: A specialized score for deploying a classic LAMP (Linux, Apache, MySQL, PHP) stack.
## OKD / Kubernetes Cluster Setup
This collection of Scores is used to provision an entire OKD cluster from bare metal. They are typically used in order.
- **OKDSetup01InventoryScore**: Discovers and catalogs the physical hardware.
- **OKDSetup02BootstrapScore**: Configures the bootstrap node, renders iPXE files, and kicks off the SCOS installation.
- **OKDSetup03ControlPlaneScore**: Renders iPXE configurations for the control plane nodes.
- **OKDSetupPersistNetworkBondScore**: Configures network bonds on the nodes and port channels on the switches.
- **OKDSetup04WorkersScore**: Renders iPXE configurations for the worker nodes.
- **OKDSetup06InstallationReportScore**: Runs post-installation checks and generates a report.
- **OKDUpgradeScore**: Manages the upgrade process for an existing OKD cluster.
## Cluster Services & Management
Scores for installing and managing services _inside_ a Kubernetes cluster.
- **K3DInstallationScore**: Installs and configes a local K3D (k3s-in-docker) cluster. Used by `K8sAnywhereTopology`.
- **CertManagerHelmScore**: Deploys the `cert-manager` Helm chart.
- **ClusterIssuerScore**: Configures a `ClusterIssuer` for `cert-manager`, (e.g., for Let's Encrypt).
- **K8sNamespaceScore**: Ensures a Kubernetes namespace exists.
- **K8sDeploymentScore**: Deploys a generic `Deployment` resource to Kubernetes.
- **K8sIngressScore**: Configures an `Ingress` resource for a service.
## Monitoring & Alerting
Scores for configuring observability, dashboards, and alerts.
- **ApplicationMonitoringScore**: A generic score to set up monitoring for an application.
- **ApplicationRHOBMonitoringScore**: A specialized score for setting up monitoring via the Red Hat Observability stack.
- **HelmPrometheusAlertingScore**: Configures Prometheus alerts via a Helm chart.
- **K8sPrometheusCRDAlertingScore**: Configures Prometheus alerts using the `PrometheusRule` CRD.
- **PrometheusAlertScore**: A generic score for creating a Prometheus alert.
- **RHOBAlertingScore**: Configures alerts specifically for the Red Hat Observability stack.
- **NtfyScore**: Configures alerts to be sent to a `ntfy.sh` server.
## Infrastructure & Networking (Bare Metal)
Low-level scores for managing physical hardware and network services.
- **DhcpScore**: Configures a DHCP server.
- **OKDDhcpScore**: A specialized DHCP configuration for the OKD bootstrap process.
- **OKDBootstrapDhcpScore**: Configures DHCP specifically for the bootstrap node.
- **DhcpHostBindingScore**: Creates a specific MAC-to-IP binding in the DHCP server.
- **DnsScore**: Configures a DNS server.
- **OKDDnsScore**: A specialized DNS configuration for the OKD cluster (e.g., `api.*`, `*.apps.*`).
- **StaticFilesHttpScore**: Serves a directory of static files (e.g., a documentation site) over HTTP.
- **TftpScore**: Configures a TFTP server, typically for serving iPXE boot files.
- **IPxeMacBootFileScore**: Assigns a specific iPXE boot file to a MAC address in the TFTP server.
- **OKDIpxeScore**: A specialized score for generating the iPXE boot scripts for OKD.
- **OPNsenseShellCommandScore**: Executes a shell command on an OPNsense firewall.
## Infrastructure & Networking (Cluster)
Network services that run inside the cluster or as part of the topology.
- **LoadBalancerScore**: Configures a general-purpose load balancer.
- **OKDLoadBalancerScore**: Configures the high-availability load balancers for the OKD API and ingress.
- **OKDBootstrapLoadBalancerScore**: Configures the load balancer specifically for the bootstrap-time API endpoint.
- **K8sIngressScore**: Configures an Ingress controller or resource.
- [HighAvailabilityHostNetworkScore](../../harmony/src/modules/okd/host_network.rs): Configures network bonds on a host and the corresponding port-channels on the switch stack for high-availability.
## Tenant Management
Scores for managing multi-tenancy within a cluster.
- **TenantScore**: Creates a new tenant (e.g., a namespace, quotas, network policies).
- **TenantCredentialScore**: Generates and provisions credentials for a new tenant.
## Utility
Helper scores for discovery and inspection.
- **LaunchDiscoverInventoryAgentScore**: Launches the agent responsible for the `OKDSetup01InventoryScore`.
- **DiscoverHostForRoleScore**: A utility score to find a host matching a specific role in the inventory.
- **InspectInventoryScore**: Dumps the discovered inventory for inspection.

View File

@@ -1,59 +0,0 @@
# Topologies Catalog
A `Topology` is the logical representation of your infrastructure and its `Capabilities`. You select a `Topology` in your Harmony project to define _where_ your `Scores` will be applied.
<!--toc:start-->
- [Topologies Catalog](#topologies-catalog)
- [HAClusterTopology](#haclustertopology)
- [K8sAnywhereTopology](#k8sanywheretopology)
<!--toc:end-->
### HAClusterTopology
- **`HAClusterTopology::autoload()`**
This `Topology` represents a high-availability, bare-metal cluster. It is designed for production-grade deployments like OKD.
It models an environment consisting of:
- At least 3 cluster nodes (for control plane/workers)
- 2 redundant firewalls (e.g., OPNsense)
- 2 redundant network switches
**Provided Capabilities:**
This topology provides a rich set of capabilities required for bare-metal provisioning and cluster management, including:
- `K8sClient` (once the cluster is bootstrapped)
- `DnsServer`
- `LoadBalancer`
- `DhcpServer`
- `TftpServer`
- `Router` (via the firewalls)
- `Switch`
- `NetworkManager` (for host-level network config)
---
### K8sAnywhereTopology
- **`K8sAnywhereTopology::from_env()`**
This `Topology` is designed for development and application deployment. It provides a simple, abstract way to deploy to _any_ Kubernetes cluster.
**How it works:**
1. By default (`from_env()` with no env vars), it automatically provisions a **local K3D (k3s-in-docker) cluster** on your machine. This is perfect for local development and testing.
2. If you provide a `KUBECONFIG` environment variable, it will instead connect to that **existing Kubernetes cluster** (e.g., your staging or production OKD cluster).
This allows you to use the _exact same code_ to deploy your application locally as you do to deploy it to production.
**Provided Capabilities:**
- `K8sClient`
- `HelmCommand`
- `TenantManager`
- `Ingress`
- `Monitoring`
- ...and more.

View File

@@ -1,40 +0,0 @@
# Core Concepts
Harmony's design is based on a few key concepts. Understanding them is the key to unlocking the framework's power.
### 1. Score
- **What it is:** A **Score** is a declarative description of a desired state. It's a "resource" that defines _what_ you want to achieve, not _how_ to do it.
- **Example:** `ApplicationScore` declares "I want this web application to be running and monitored."
### 2. Topology
- **What it is:** A **Topology** is the logical representation of your infrastructure and its abilities. It's the "where" your Scores will be applied.
- **Key Job:** A Topology's most important job is to expose which `Capabilities` it supports.
- **Example:** `HAClusterTopology` represents a bare-metal cluster and exposes `Capabilities` like `NetworkManager` and `Switch`. `K8sAnywhereTopology` represents a Kubernetes cluster and exposes the `K8sClient` `Capability`.
### 3. Capability
- **What it is:** A **Capability** is a specific feature or API that a `Topology` offers. It's the "how" a `Topology` can fulfill a `Score`'s request.
- **Example:** The `K8sClient` capability offers a way to interact with a Kubernetes API. The `Switch` capability offers a way to configure a physical network switch.
### 4. Interpret
- **What it is:** An **Interpret** is the execution logic that makes a `Score` a reality. It's the "glue" that connects the _desired state_ (`Score`) to the _environment's abilities_ (`Topology`'s `Capabilities`).
- **How it works:** When you apply a `Score`, Harmony finds the matching `Interpret` for your `Topology`. This `Interpret` then uses the `Capabilities` provided by the `Topology` to execute the necessary steps.
### 5. Inventory
- **What it is:** An **Inventory** is the physical material (the "what") used in a cluster. This is most relevant for bare-metal or on-premise topologies.
- **Example:** A list of nodes with their roles (control plane, worker), CPU, RAM, and network interfaces. For the `K8sAnywhereTopology`, the inventory might be empty or autoloaded, as the infrastructure is more abstract.
---
### How They Work Together (The Compile-Time Check)
1. You **write a `Score`** (e.g., `ApplicationScore`).
2. Your `Score`'s `Interpret` logic requires certain **`Capabilities`** (e.g., `K8sClient` and `Ingress`).
3. You choose a **`Topology`** to run it on (e.g., `HAClusterTopology`).
4. **At compile-time**, Harmony checks: "Does `HAClusterTopology` provide the `K8sClient` and `Ingress` capabilities that `ApplicationScore` needs?"
- **If Yes:** Your code compiles. You can be confident it will run.
- **If No:** The compiler gives you an error. You've just prevented a "config-is-valid-but-platform-is-wrong" runtime error before you even deployed.

View File

@@ -1,42 +0,0 @@
# Getting Started Guide
Welcome to Harmony! This guide will walk you through installing the Harmony framework, setting up a new project, and deploying your first application.
We will build and deploy the "Rust Web App" example, which automatically:
1. Provisions a local K3D (Kubernetes in Docker) cluster.
2. Deploys a sample Rust web application.
3. Sets up monitoring for the application.
## Prerequisites
Before you begin, you'll need a few tools installed on your system:
- **Rust & Cargo:** [Install Rust](https://www.rust-lang.org/tools/install)
- **Docker:** [Install Docker](https://docs.docker.com/get-docker/) (Required for the K3D local cluster)
- **kubectl:** [Install kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) (For inspecting the cluster)
## 1. Install Harmony
First, clone the Harmony repository and build the project. This gives you the `harmony` CLI and all the core libraries.
```bash
# Clone the main repository
git clone https://git.nationtech.io/nationtech/harmony
cd harmony
# Build the project (this may take a few minutes)
cargo build --release
```
...
## Next Steps
Congratulations, you've just deployed an application using true infrastructure-as-code!
From here, you can:
- [Explore the Catalogs](../catalogs/README.md): See what other [Scores](../catalogs/scores.md) and [Topologies](../catalogs/topologies.md) are available.
- [Read the Use Cases](../use-cases/README.md): Check out the [OKD on Bare Metal](./use-cases/okd-on-bare-metal.md) guide for a more advanced scenario.
- [Write your own Score](../guides/writing-a-score.md): Dive into the [Developer Guide](./guides/developer-guide.md) to start building your own components.

View File

@@ -1,28 +1,22 @@
use std::str::FromStr;
use async_trait::async_trait;
use brocade::{BrocadeOptions, PortOperatingMode};
use harmony::{
infra::brocade::BrocadeSwitchConfig,
data::Version,
infra::brocade::BrocadeSwitchClient,
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
inventory::Inventory,
modules::brocade::{BrocadeSwitchAuth, BrocadeSwitchScore, SwitchTopology},
score::Score,
topology::{
HostNetworkConfig, PortConfig, PreparationError, PreparationOutcome, Switch, SwitchClient,
SwitchError, Topology,
},
};
use harmony_macros::ip;
use harmony_types::{id::Id, switch::PortLocation};
fn get_switch_config() -> BrocadeSwitchConfig {
let mut options = BrocadeOptions::default();
options.ssh.port = 2222;
let auth = BrocadeSwitchAuth {
username: "admin".to_string(),
password: "password".to_string(),
};
BrocadeSwitchConfig {
ips: vec![ip!("127.0.0.1")],
auth,
options,
}
}
use harmony_types::{id::Id, net::MacAddress, switch::PortLocation};
use log::{debug, info};
use serde::Serialize;
#[tokio::main]
async fn main() {
@@ -38,13 +32,126 @@ async fn main() {
(PortLocation(1, 0, 18), PortOperatingMode::Trunk),
],
};
harmony_cli::run(
Inventory::autoload(),
SwitchTopology::new(get_switch_config()).await,
SwitchTopology::new().await,
vec![Box::new(switch_score)],
None,
)
.await
.unwrap();
}
#[derive(Clone, Debug, Serialize)]
struct BrocadeSwitchScore {
port_channels_to_clear: Vec<Id>,
ports_to_configure: Vec<PortConfig>,
}
impl<T: Topology + Switch> Score<T> for BrocadeSwitchScore {
fn name(&self) -> String {
"BrocadeSwitchScore".to_string()
}
#[doc(hidden)]
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(BrocadeSwitchInterpret {
score: self.clone(),
})
}
}
#[derive(Debug)]
struct BrocadeSwitchInterpret {
score: BrocadeSwitchScore,
}
#[async_trait]
impl<T: Topology + Switch> Interpret<T> for BrocadeSwitchInterpret {
async fn execute(
&self,
_inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
info!("Applying switch configuration {:?}", self.score);
debug!(
"Clearing port channel {:?}",
self.score.port_channels_to_clear
);
topology
.clear_port_channel(&self.score.port_channels_to_clear)
.await
.map_err(|e| InterpretError::new(e.to_string()))?;
debug!("Configuring interfaces {:?}", self.score.ports_to_configure);
topology
.configure_interface(&self.score.ports_to_configure)
.await
.map_err(|e| InterpretError::new(e.to_string()))?;
Ok(Outcome::success("switch configured".to_string()))
}
fn get_name(&self) -> InterpretName {
InterpretName::Custom("BrocadeSwitchInterpret")
}
fn get_version(&self) -> Version {
todo!()
}
fn get_status(&self) -> InterpretStatus {
todo!()
}
fn get_children(&self) -> Vec<Id> {
todo!()
}
}
struct SwitchTopology {
client: Box<dyn SwitchClient>,
}
#[async_trait]
impl Topology for SwitchTopology {
fn name(&self) -> &str {
"SwitchTopology"
}
async fn ensure_ready(&self) -> Result<PreparationOutcome, PreparationError> {
Ok(PreparationOutcome::Noop)
}
}
impl SwitchTopology {
async fn new() -> Self {
let mut options = BrocadeOptions::default();
options.ssh.port = 2222;
let client =
BrocadeSwitchClient::init(&vec![ip!("127.0.0.1")], &"admin", &"password", options)
.await
.expect("Failed to connect to switch");
let client = Box::new(client);
Self { client }
}
}
#[async_trait]
impl Switch for SwitchTopology {
async fn setup_switch(&self) -> Result<(), SwitchError> {
todo!()
}
async fn get_port_for_mac_address(
&self,
_mac_address: &MacAddress,
) -> Result<Option<PortLocation>, SwitchError> {
todo!()
}
async fn configure_port_channel(&self, _config: &HostNetworkConfig) -> Result<(), SwitchError> {
todo!()
}
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError> {
self.client.clear_port_channel(ids).await
}
async fn configure_interface(&self, ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
self.client.configure_interface(ports).await
}
}

View File

@@ -1,8 +1,8 @@
use harmony::{
inventory::Inventory,
modules::cert_manager::{
capability::CertificateManagementConfig, score_certificate::CertificateScore,
score_issuer::CertificateIssuerScore,
capability::CertificateManagementConfig, score_cert_management::CertificateManagementScore,
score_certificate::CertificateScore, score_issuer::CertificateIssuerScore,
},
topology::K8sAnywhereTopology,
};

View File

@@ -1,16 +0,0 @@
[workspace]
[package]
name = "example-cluster-dashboards"
edition = "2021"
version = "0.1.0"
license = "GNU AGPL v3"
publish = false
[dependencies]
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_types = { path = "../../harmony_types" }
tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] }
log = "0.4"
env_logger = "0.11"

View File

@@ -1,21 +0,0 @@
use harmony::{
inventory::Inventory,
modules::monitoring::cluster_dashboards::ClusterDashboardsScore,
topology::K8sAnywhereTopology,
};
#[tokio::main]
async fn main() {
harmony_cli::cli_logger::init();
let cluster_dashboards_score = ClusterDashboardsScore::default();
harmony_cli::run(
Inventory::autoload(),
K8sAnywhereTopology::from_env(),
vec![Box::new(cluster_dashboards_score)],
None,
)
.await
.unwrap();
}

View File

@@ -1,21 +0,0 @@
[package]
name = "example-k8s-drain-node"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
publish = false
[dependencies]
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_types = { path = "../../harmony_types" }
harmony_macros = { path = "../../harmony_macros" }
harmony-k8s = { path = "../../harmony-k8s" }
cidr.workspace = true
tokio.workspace = true
log.workspace = true
env_logger.workspace = true
url.workspace = true
assert_cmd = "2.0.16"
inquire.workspace = true

View File

@@ -1,61 +0,0 @@
use std::time::Duration;
use harmony_k8s::{DrainOptions, K8sClient};
use log::{info, trace};
#[tokio::main]
async fn main() {
env_logger::init();
let k8s = K8sClient::try_default().await.unwrap();
let nodes = k8s.get_nodes(None).await.unwrap();
trace!("Got nodes : {nodes:#?}");
let node_names = nodes
.iter()
.map(|n| n.metadata.name.as_ref().unwrap())
.collect::<Vec<&String>>();
info!("Got nodes : {:?}", node_names);
let node_name = inquire::Select::new("What node do you want to operate on?", node_names)
.prompt()
.unwrap();
let drain = inquire::Confirm::new("Do you wish to drain the node now ?")
.prompt()
.unwrap();
if drain {
let mut options = DrainOptions::default_ignore_daemonset_delete_emptydir_data();
options.timeout = Duration::from_secs(1);
k8s.drain_node(&node_name, &options).await.unwrap();
info!("Node {node_name} successfully drained");
}
let uncordon =
inquire::Confirm::new("Do you wish to uncordon node to resume scheduling workloads now?")
.prompt()
.unwrap();
if uncordon {
info!("Uncordoning node {node_name}");
k8s.uncordon_node(node_name).await.unwrap();
info!("Node {node_name} uncordoned");
}
let reboot = inquire::Confirm::new("Do you wish to reboot node now?")
.prompt()
.unwrap();
if reboot {
k8s.reboot_node(
&node_name,
&DrainOptions::default_ignore_daemonset_delete_emptydir_data(),
Duration::from_secs(3600),
)
.await
.unwrap();
}
info!("All done playing with nodes, happy harmonizing!");
}

View File

@@ -1,21 +0,0 @@
[package]
name = "example-k8s-write-file-on-node"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
publish = false
[dependencies]
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_types = { path = "../../harmony_types" }
harmony_macros = { path = "../../harmony_macros" }
harmony-k8s = { path = "../../harmony-k8s" }
cidr.workspace = true
tokio.workspace = true
log.workspace = true
env_logger.workspace = true
url.workspace = true
assert_cmd = "2.0.16"
inquire.workspace = true

View File

@@ -1,45 +0,0 @@
use harmony_k8s::{K8sClient, NodeFile};
use log::{info, trace};
#[tokio::main]
async fn main() {
env_logger::init();
let k8s = K8sClient::try_default().await.unwrap();
let nodes = k8s.get_nodes(None).await.unwrap();
trace!("Got nodes : {nodes:#?}");
let node_names = nodes
.iter()
.map(|n| n.metadata.name.as_ref().unwrap())
.collect::<Vec<&String>>();
info!("Got nodes : {:?}", node_names);
let node = inquire::Select::new("What node do you want to write file to?", node_names)
.prompt()
.unwrap();
let path = inquire::Text::new("File path on node").prompt().unwrap();
let content = inquire::Text::new("File content").prompt().unwrap();
let node_file = NodeFile {
path: path,
content: content,
mode: 0o600,
};
k8s.write_files_to_node(&node, &vec![node_file.clone()])
.await
.unwrap();
let cmd = inquire::Text::new("Command to run on node")
.prompt()
.unwrap();
k8s.run_privileged_command_on_node(&node, &cmd)
.await
.unwrap();
info!(
"File {} mode {} written in node {node}",
node_file.path, node_file.mode
);
}

View File

@@ -215,7 +215,7 @@ fn site(
dns_name: format!("{cluster_name}-gw.{domain}"),
supercluster_ca_secret_name: "nats-supercluster-ca-bundle",
tls_cert_name: "nats-gateway",
jetstream_enabled: "true",
jetstream_enabled: "false",
},
}
}

View File

@@ -1,16 +0,0 @@
[package]
name = "example-node-health"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
publish = false
[dependencies]
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_types = { path = "../../harmony_types" }
tokio = { workspace = true }
harmony_macros = { path = "../../harmony_macros" }
log = { workspace = true }
env_logger = { workspace = true }

View File

@@ -1,17 +0,0 @@
use harmony::{
inventory::Inventory, modules::node_health::NodeHealthScore, topology::K8sAnywhereTopology,
};
#[tokio::main]
async fn main() {
let node_health = NodeHealthScore {};
harmony_cli::run(
Inventory::autoload(),
K8sAnywhereTopology::from_env(),
vec![Box::new(node_health)],
None,
)
.await
.unwrap();
}

View File

@@ -6,10 +6,7 @@ use harmony::{
data::{FileContent, FilePath},
modules::{
inventory::HarmonyDiscoveryStrategy,
okd::{
installation::OKDInstallationPipeline, ipxe::OKDIpxeScore,
load_balancer::OKDLoadBalancerScore,
},
okd::{installation::OKDInstallationPipeline, ipxe::OKDIpxeScore},
},
score::Score,
topology::HAClusterTopology,
@@ -35,7 +32,6 @@ async fn main() {
scores
.append(&mut OKDInstallationPipeline::get_all_scores(HarmonyDiscoveryStrategy::MDNS).await);
scores.push(Box::new(OKDLoadBalancerScore::new(&topology)));
harmony_cli::run(inventory, topology, scores, None)
.await
.unwrap();

View File

@@ -2,12 +2,8 @@ use brocade::BrocadeOptions;
use cidr::Ipv4Cidr;
use harmony::{
hardware::{Location, SwitchGroup},
infra::{
brocade::{BrocadeSwitchClient, BrocadeSwitchConfig},
opnsense::OPNSenseManagementInterface,
},
infra::{brocade::BrocadeSwitchClient, opnsense::OPNSenseManagementInterface},
inventory::Inventory,
modules::brocade::BrocadeSwitchAuth,
topology::{HAClusterTopology, LogicalHost, UnmanagedRouter},
};
use harmony_macros::{ip, ipv4};
@@ -40,11 +36,12 @@ pub async fn get_topology() -> HAClusterTopology {
dry_run: *harmony::config::DRY_RUN,
..Default::default()
};
let switch_client = BrocadeSwitchClient::init(BrocadeSwitchConfig {
ips: switches,
auth: switch_auth,
options: brocade_options,
})
let switch_client = BrocadeSwitchClient::init(
&switches,
&switch_auth.username,
&switch_auth.password,
brocade_options,
)
.await
.expect("Failed to connect to switch");
@@ -106,3 +103,9 @@ pub fn get_inventory() -> Inventory {
control_plane_host: vec![],
}
}
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug)]
pub struct BrocadeSwitchAuth {
pub username: String,
pub password: String,
}

View File

@@ -3,16 +3,14 @@ use cidr::Ipv4Cidr;
use harmony::{
config::secret::OPNSenseFirewallCredentials,
hardware::{Location, SwitchGroup},
infra::{
brocade::{BrocadeSwitchClient, BrocadeSwitchConfig},
opnsense::OPNSenseManagementInterface,
},
infra::{brocade::BrocadeSwitchClient, opnsense::OPNSenseManagementInterface},
inventory::Inventory,
modules::brocade::BrocadeSwitchAuth,
topology::{HAClusterTopology, LogicalHost, UnmanagedRouter},
};
use harmony_macros::{ip, ipv4};
use harmony_secret::SecretManager;
use harmony_secret::{Secret, SecretManager};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
net::IpAddr,
sync::{Arc, OnceLock},
@@ -33,11 +31,12 @@ pub async fn get_topology() -> HAClusterTopology {
dry_run: *harmony::config::DRY_RUN,
..Default::default()
};
let switch_client = BrocadeSwitchClient::init(BrocadeSwitchConfig {
ips: switches,
auth: switch_auth,
options: brocade_options,
})
let switch_client = BrocadeSwitchClient::init(
&switches,
&switch_auth.username,
&switch_auth.password,
brocade_options,
)
.await
.expect("Failed to connect to switch");
@@ -99,3 +98,9 @@ pub fn get_inventory() -> Inventory {
control_plane_host: vec![],
}
}
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug)]
pub struct BrocadeSwitchAuth {
pub username: String,
pub password: String,
}

View File

@@ -1,13 +1,63 @@
use std::str::FromStr;
use harmony::{
inventory::Inventory, modules::openbao::OpenbaoScore, topology::K8sAnywhereTopology,
inventory::Inventory,
modules::helm::chart::{HelmChartScore, HelmRepository, NonBlankString},
topology::K8sAnywhereTopology,
};
use harmony_macros::hurl;
#[tokio::main]
async fn main() {
let openbao = OpenbaoScore {
host: "openbao.sebastien.sto1.nationtech.io".to_string(),
let values_yaml = Some(
r#"server:
standalone:
enabled: true
config: |
listener "tcp" {
tls_disable = true
address = "[::]:8200"
cluster_address = "[::]:8201"
}
storage "file" {
path = "/openbao/data"
}
service:
enabled: true
dataStorage:
enabled: true
size: 10Gi
storageClass: null
accessMode: ReadWriteOnce
auditStorage:
enabled: true
size: 10Gi
storageClass: null
accessMode: ReadWriteOnce"#
.to_string(),
);
let openbao = HelmChartScore {
namespace: Some(NonBlankString::from_str("openbao").unwrap()),
release_name: NonBlankString::from_str("openbao").unwrap(),
chart_name: NonBlankString::from_str("openbao/openbao").unwrap(),
chart_version: None,
values_overrides: None,
values_yaml,
create_namespace: true,
install_only: true,
repository: Some(HelmRepository::new(
"openbao".to_string(),
hurl!("https://openbao.github.io/openbao-helm"),
true,
)),
};
// TODO exec pod commands to initialize secret store if not already done
harmony_cli::run(
Inventory::autoload(),
K8sAnywhereTopology::from_env(),

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use harmony::{
inventory::Inventory,
modules::{k8s::apps::OperatorHubCatalogSourceScore, postgresql::CloudNativePgOperatorScore},
@@ -7,7 +9,7 @@ use harmony::{
#[tokio::main]
async fn main() {
let operatorhub_catalog = OperatorHubCatalogSourceScore::default();
let cnpg_operator = CloudNativePgOperatorScore::default_openshift();
let cnpg_operator = CloudNativePgOperatorScore::default();
harmony_cli::run(
Inventory::autoload(),

View File

@@ -1,13 +1,22 @@
use std::sync::Arc;
use std::{
net::{IpAddr, Ipv4Addr},
sync::Arc,
};
use async_trait::async_trait;
use cidr::Ipv4Cidr;
use harmony::{
executors::ExecutorError,
hardware::{HostCategory, Location, PhysicalHost, SwitchGroup},
infra::opnsense::OPNSenseManagementInterface,
inventory::Inventory,
modules::opnsense::node_exporter::NodeExporterScore,
topology::{PreparationError, PreparationOutcome, Topology, node_exporter::NodeExporter},
topology::{
HAClusterTopology, LogicalHost, PreparationError, PreparationOutcome, Topology,
UnmanagedRouter, node_exporter::NodeExporter,
},
};
use harmony_macros::ip;
use harmony_macros::{ip, ipv4, mac_address};
#[derive(Debug)]
struct OpnSenseTopology {

View File

@@ -1,7 +1,8 @@
use harmony::{
inventory::Inventory,
modules::postgresql::{
PostgreSQLConnectionScore, PublicPostgreSQLScore, capability::PostgreSQLConfig,
K8sPostgreSQLScore, PostgreSQLConnectionScore, PublicPostgreSQLScore,
capability::PostgreSQLConfig,
},
topology::K8sAnywhereTopology,
};

View File

@@ -1,4 +1,4 @@
use std::{path::PathBuf, sync::Arc};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use harmony::{
inventory::Inventory,

View File

@@ -1,4 +1,4 @@
use std::{path::PathBuf, sync::Arc};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use harmony::{
inventory::Inventory,

View File

@@ -5,10 +5,6 @@ version.workspace = true
readme.workspace = true
license.workspace = true
[[example]]
name = "try_rust_webapp"
path = "src/main.rs"
[dependencies]
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }

View File

@@ -44,7 +44,6 @@ fn build_large_score() -> LoadBalancerScore {
],
listening_port: SocketAddr::V4(SocketAddrV4::new(ipv4!("192.168.0.0"), 49387)),
health_check: Some(HealthCheck::HTTP(
Some(1993),
"/some_long_ass_path_to_see_how_it_is_displayed_but_it_has_to_be_even_longer"
.to_string(),
HttpMethod::GET,

View File

@@ -1,14 +0,0 @@
[package]
name = "example-zitadel"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
[dependencies]
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_macros = { path = "../../harmony_macros" }
harmony_types = { path = "../../harmony_types" }
tokio.workspace = true
url.workspace = true

View File

@@ -1,20 +0,0 @@
use harmony::{
inventory::Inventory, modules::zitadel::ZitadelScore, topology::K8sAnywhereTopology,
};
#[tokio::main]
async fn main() {
let zitadel = ZitadelScore {
host: "sso.sto1.nationtech.io".to_string(),
zitadel_version: "v4.12.1".to_string(),
};
harmony_cli::run(
Inventory::autoload(),
K8sAnywhereTopology::from_env(),
vec![Box::new(zitadel)],
None,
)
.await
.unwrap();
}

Binary file not shown.

View File

@@ -1,23 +0,0 @@
[package]
name = "harmony-k8s"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
[dependencies]
kube.workspace = true
k8s-openapi.workspace = true
tokio.workspace = true
tokio-retry.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
log.workspace = true
similar.workspace = true
reqwest.workspace = true
url.workspace = true
inquire.workspace = true
[dev-dependencies]
pretty_assertions.workspace = true

View File

@@ -1,593 +0,0 @@
use kube::{
Client, Error, Resource,
api::{
Api, ApiResource, DynamicObject, GroupVersionKind, Patch, PatchParams, PostParams,
ResourceExt,
},
core::ErrorResponse,
discovery::Scope,
error::DiscoveryError,
};
use log::{debug, error, trace, warn};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
use similar::TextDiff;
use url::Url;
use crate::client::K8sClient;
use crate::helper;
use crate::types::WriteMode;
/// The field-manager token sent with every server-side apply request.
pub const FIELD_MANAGER: &str = "harmony-k8s";
// ── Private helpers ──────────────────────────────────────────────────────────
/// Serialise any `Serialize` payload to a [`DynamicObject`] via JSON.
fn to_dynamic<T: Serialize>(payload: &T) -> Result<DynamicObject, Error> {
serde_json::from_value(serde_json::to_value(payload).map_err(Error::SerdeError)?)
.map_err(Error::SerdeError)
}
/// Fetch the current resource, display a unified diff against `payload`, and
/// return `()`. All output goes to stdout (same behaviour as before).
///
/// A 404 is treated as "resource would be created" — not an error.
async fn show_dry_run<T: Serialize>(
api: &Api<DynamicObject>,
name: &str,
payload: &T,
) -> Result<(), Error> {
let new_yaml = serde_yaml::to_string(payload)
.unwrap_or_else(|_| "Failed to serialize new resource".to_string());
match api.get(name).await {
Ok(current) => {
println!("\nDry-run for resource: '{name}'");
let mut current_val = serde_yaml::to_value(&current).unwrap_or(serde_yaml::Value::Null);
if let Some(map) = current_val.as_mapping_mut() {
map.remove(&serde_yaml::Value::String("status".to_string()));
}
let current_yaml = serde_yaml::to_string(&current_val)
.unwrap_or_else(|_| "Failed to serialize current resource".to_string());
if current_yaml == new_yaml {
println!("No changes detected.");
} else {
println!("Changes detected:");
let diff = TextDiff::from_lines(&current_yaml, &new_yaml);
for change in diff.iter_all_changes() {
let sign = match change.tag() {
similar::ChangeTag::Delete => "-",
similar::ChangeTag::Insert => "+",
similar::ChangeTag::Equal => " ",
};
print!("{sign}{change}");
}
}
Ok(())
}
Err(Error::Api(ErrorResponse { code: 404, .. })) => {
println!("\nDry-run for new resource: '{name}'");
println!("Resource does not exist. Would be created:");
for line in new_yaml.lines() {
println!("+{line}");
}
Ok(())
}
Err(e) => {
error!("Failed to fetch resource '{name}' for dry-run: {e}");
Err(e)
}
}
}
/// Execute the real (non-dry-run) apply, respecting [`WriteMode`].
async fn do_apply<T: Serialize + std::fmt::Debug>(
api: &Api<DynamicObject>,
name: &str,
payload: &T,
patch_params: &PatchParams,
write_mode: &WriteMode,
) -> Result<DynamicObject, Error> {
match write_mode {
WriteMode::CreateOrUpdate => {
// TODO refactor this arm to perform self.update and if fail with 404 self.create
// This will avoid the repetition of the api.patch and api.create calls within this
// function body. This makes the code more maintainable
match api.patch(name, patch_params, &Patch::Apply(payload)).await {
Ok(obj) => Ok(obj),
Err(Error::Api(ErrorResponse { code: 404, .. })) => {
debug!("Resource '{name}' not found via SSA, falling back to POST");
let dyn_obj = to_dynamic(payload)?;
api.create(&PostParams::default(), &dyn_obj)
.await
.map_err(|e| {
error!("Failed to create '{name}': {e}");
e
})
}
Err(e) => {
error!("Failed to apply '{name}': {e}");
Err(e)
}
}
}
WriteMode::Create => {
let dyn_obj = to_dynamic(payload)?;
api.create(&PostParams::default(), &dyn_obj)
.await
.map_err(|e| {
error!("Failed to create '{name}': {e}");
e
})
}
WriteMode::Update => match api.patch(name, patch_params, &Patch::Apply(payload)).await {
Ok(obj) => Ok(obj),
Err(Error::Api(ErrorResponse { code: 404, .. })) => Err(Error::Api(ErrorResponse {
code: 404,
message: format!("Resource '{name}' not found and WriteMode is UpdateOnly"),
reason: "NotFound".to_string(),
status: "Failure".to_string(),
})),
Err(e) => {
error!("Failed to update '{name}': {e}");
Err(e)
}
},
}
}
// ── Public API ───────────────────────────────────────────────────────────────
impl K8sClient {
/// Server-side apply: create if absent, update if present.
/// Equivalent to `kubectl apply`.
pub async fn apply<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
<K as Resource>::DynamicType: Default,
{
self.apply_with_strategy(resource, namespace, WriteMode::CreateOrUpdate)
.await
}
/// POST only — returns an error if the resource already exists.
pub async fn create<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
<K as Resource>::DynamicType: Default,
{
self.apply_with_strategy(resource, namespace, WriteMode::Create)
.await
}
/// Server-side apply only — returns an error if the resource does not exist.
pub async fn update<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
<K as Resource>::DynamicType: Default,
{
self.apply_with_strategy(resource, namespace, WriteMode::Update)
.await
}
pub async fn apply_with_strategy<K>(
&self,
resource: &K,
namespace: Option<&str>,
write_mode: WriteMode,
) -> Result<K, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
<K as Resource>::DynamicType: Default,
{
debug!(
"apply_with_strategy: {:?} ns={:?}",
resource.meta().name,
namespace
);
trace!("{:#}", serde_json::to_value(resource).unwrap_or_default());
let dyntype = K::DynamicType::default();
let gvk = GroupVersionKind {
group: K::group(&dyntype).to_string(),
version: K::version(&dyntype).to_string(),
kind: K::kind(&dyntype).to_string(),
};
let discovery = self.discovery().await?;
let (ar, caps) = discovery.resolve_gvk(&gvk).ok_or_else(|| {
Error::Discovery(DiscoveryError::MissingResource(format!(
"Cannot resolve GVK: {gvk:?}"
)))
})?;
let effective_ns = if caps.scope == Scope::Cluster {
None
} else {
namespace.or_else(|| resource.meta().namespace.as_deref())
};
let api: Api<DynamicObject> =
get_dynamic_api(ar, caps, self.client.clone(), effective_ns, false);
let name = resource
.meta()
.name
.as_deref()
.expect("Kubernetes resource must have a name");
if self.dry_run {
show_dry_run(&api, name, resource).await?;
return Ok(resource.clone());
}
let patch_params = PatchParams::apply(FIELD_MANAGER);
do_apply(&api, name, resource, &patch_params, &write_mode)
.await
.and_then(helper::dyn_to_typed)
}
/// Applies resources in order, one at a time
pub async fn apply_many<K>(&self, resources: &[K], ns: Option<&str>) -> Result<Vec<K>, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
<K as Resource>::DynamicType: Default,
{
let mut result = Vec::new();
for r in resources.iter() {
let res = self.apply(r, ns).await;
if res.is_err() {
// NOTE: this may log sensitive data; downgrade to debug if needed.
warn!(
"Failed to apply k8s resource: {}",
serde_json::to_string_pretty(r).map_err(Error::SerdeError)?
);
}
result.push(res?);
}
Ok(result)
}
/// Apply a [`DynamicObject`] resource using server-side apply.
pub async fn apply_dynamic(
&self,
resource: &DynamicObject,
namespace: Option<&str>,
force_conflicts: bool,
) -> Result<DynamicObject, Error> {
trace!("apply_dynamic {resource:#?} ns={namespace:?} force={force_conflicts}");
let discovery = self.discovery().await?;
let type_meta = resource.types.as_ref().ok_or_else(|| {
Error::BuildRequest(kube::core::request::Error::Validation(
"DynamicObject must have types (apiVersion and kind)".to_string(),
))
})?;
let gvk = GroupVersionKind::try_from(type_meta).map_err(|_| {
Error::BuildRequest(kube::core::request::Error::Validation(format!(
"Invalid GVK in DynamicObject: {type_meta:?}"
)))
})?;
let (ar, caps) = discovery.resolve_gvk(&gvk).ok_or_else(|| {
Error::Discovery(DiscoveryError::MissingResource(format!(
"Cannot resolve GVK: {gvk:?}"
)))
})?;
let effective_ns = if caps.scope == Scope::Cluster {
None
} else {
namespace.or_else(|| resource.metadata.namespace.as_deref())
};
let api = get_dynamic_api(ar, caps, self.client.clone(), effective_ns, false);
let name = resource.metadata.name.as_deref().ok_or_else(|| {
Error::BuildRequest(kube::core::request::Error::Validation(
"DynamicObject must have metadata.name".to_string(),
))
})?;
debug!(
"apply_dynamic kind={:?} name='{name}' ns={effective_ns:?}",
resource.types.as_ref().map(|t| &t.kind),
);
// NOTE would be nice to improve cohesion between the dynamic and typed apis and avoid copy
// pasting the dry_run and some more logic
if self.dry_run {
show_dry_run(&api, name, resource).await?;
return Ok(resource.clone());
}
let mut patch_params = PatchParams::apply(FIELD_MANAGER);
patch_params.force = force_conflicts;
do_apply(
&api,
name,
resource,
&patch_params,
&WriteMode::CreateOrUpdate,
)
.await
}
pub async fn apply_dynamic_many(
&self,
resources: &[DynamicObject],
namespace: Option<&str>,
force_conflicts: bool,
) -> Result<Vec<DynamicObject>, Error> {
let mut result = Vec::new();
for r in resources.iter() {
result.push(self.apply_dynamic(r, namespace, force_conflicts).await?);
}
Ok(result)
}
pub async fn apply_yaml_many(
&self,
#[allow(clippy::ptr_arg)] yaml: &Vec<serde_yaml::Value>,
ns: Option<&str>,
) -> Result<(), Error> {
for y in yaml.iter() {
self.apply_yaml(y, ns).await?;
}
Ok(())
}
pub async fn apply_yaml(
&self,
yaml: &serde_yaml::Value,
ns: Option<&str>,
) -> Result<(), Error> {
// NOTE wouldn't it be possible to parse this into a DynamicObject and simply call
// apply_dynamic instead of reimplementing api interactions?
let obj: DynamicObject =
serde_yaml::from_value(yaml.clone()).expect("YAML must deserialise to DynamicObject");
let name = obj.metadata.name.as_ref().expect("YAML must have a name");
let api_version = yaml["apiVersion"].as_str().expect("missing apiVersion");
let kind = yaml["kind"].as_str().expect("missing kind");
let mut it = api_version.splitn(2, '/');
let first = it.next().unwrap();
let (g, v) = match it.next() {
Some(second) => (first, second),
None => ("", first),
};
let api_resource = ApiResource::from_gvk(&GroupVersionKind::gvk(g, v, kind));
let namespace = ns.unwrap_or_else(|| {
obj.metadata
.namespace
.as_deref()
.expect("YAML must have a namespace when ns is not provided")
});
let api: Api<DynamicObject> =
Api::namespaced_with(self.client.clone(), namespace, &api_resource);
println!("Applying '{name}' in namespace '{namespace}'...");
let patch_params = PatchParams::apply(FIELD_MANAGER);
let result = api.patch(name, &patch_params, &Patch::Apply(&obj)).await?;
println!("Successfully applied '{}'.", result.name_any());
Ok(())
}
/// Equivalent to `kubectl apply -f <url>`.
pub async fn apply_url(&self, url: Url, ns: Option<&str>) -> Result<(), Error> {
let patch_params = PatchParams::apply(FIELD_MANAGER);
let discovery = self.discovery().await?;
let yaml = reqwest::get(url)
.await
.expect("Could not fetch URL")
.text()
.await
.expect("Could not read response body");
for doc in multidoc_deserialize(&yaml).expect("Failed to parse YAML from URL") {
let obj: DynamicObject =
serde_yaml::from_value(doc).expect("YAML document is not a valid object");
let namespace = obj.metadata.namespace.as_deref().or(ns);
let type_meta = obj.types.as_ref().expect("Object is missing TypeMeta");
let gvk =
GroupVersionKind::try_from(type_meta).expect("Object has invalid GroupVersionKind");
let name = obj.name_any();
if let Some((ar, caps)) = discovery.resolve_gvk(&gvk) {
let api = get_dynamic_api(ar, caps, self.client.clone(), namespace, false);
trace!(
"Applying {}:\n{}",
gvk.kind,
serde_yaml::to_string(&obj).unwrap_or_default()
);
let data: Value = serde_json::to_value(&obj).expect("serialisation failed");
let _r = api.patch(&name, &patch_params, &Patch::Apply(data)).await?;
debug!("Applied {} '{name}'", gvk.kind);
} else {
warn!("Skipping document with unknown GVK: {gvk:?}");
}
}
Ok(())
}
/// Build a dynamic API client from a [`DynamicObject`]'s type metadata.
pub(crate) fn get_api_for_dynamic_object(
&self,
object: &DynamicObject,
ns: Option<&str>,
) -> Result<Api<DynamicObject>, Error> {
let ar = object
.types
.as_ref()
.and_then(|t| {
let parts: Vec<&str> = t.api_version.split('/').collect();
match parts.as_slice() {
[version] => Some(ApiResource::from_gvk(&GroupVersionKind::gvk(
"", version, &t.kind,
))),
[group, version] => Some(ApiResource::from_gvk(&GroupVersionKind::gvk(
group, version, &t.kind,
))),
_ => None,
}
})
.ok_or_else(|| {
Error::BuildRequest(kube::core::request::Error::Validation(format!(
"Invalid apiVersion in DynamicObject: {object:#?}"
)))
})?;
Ok(match ns {
Some(ns) => Api::namespaced_with(self.client.clone(), ns, &ar),
None => Api::default_namespaced_with(self.client.clone(), &ar),
})
}
}
// ── Free functions ───────────────────────────────────────────────────────────
pub(crate) fn get_dynamic_api(
resource: kube::api::ApiResource,
capabilities: kube::discovery::ApiCapabilities,
client: Client,
ns: Option<&str>,
all: bool,
) -> Api<DynamicObject> {
if capabilities.scope == Scope::Cluster || all {
Api::all_with(client, &resource)
} else if let Some(namespace) = ns {
Api::namespaced_with(client, namespace, &resource)
} else {
Api::default_namespaced_with(client, &resource)
}
}
pub(crate) fn multidoc_deserialize(
data: &str,
) -> Result<Vec<serde_yaml::Value>, serde_yaml::Error> {
use serde::Deserialize;
let mut docs = vec![];
for de in serde_yaml::Deserializer::from_str(data) {
docs.push(serde_yaml::Value::deserialize(de)?);
}
Ok(docs)
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod apply_tests {
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use k8s_openapi::api::core::v1::ConfigMap;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use kube::api::{DeleteParams, TypeMeta};
use super::*;
#[tokio::test]
#[ignore = "requires kubernetes cluster"]
async fn apply_creates_new_configmap() {
let client = K8sClient::try_default().await.unwrap();
let ns = "default";
let name = format!(
"test-cm-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis()
);
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some(name.clone()),
namespace: Some(ns.to_string()),
..Default::default()
},
data: Some(BTreeMap::from([("key1".to_string(), "value1".to_string())])),
..Default::default()
};
assert!(client.apply(&cm, Some(ns)).await.is_ok());
let api: Api<ConfigMap> = Api::namespaced(client.client.clone(), ns);
let _ = api.delete(&name, &DeleteParams::default()).await;
}
#[tokio::test]
#[ignore = "requires kubernetes cluster"]
async fn apply_is_idempotent() {
let client = K8sClient::try_default().await.unwrap();
let ns = "default";
let name = format!(
"test-idem-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis()
);
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some(name.clone()),
namespace: Some(ns.to_string()),
..Default::default()
},
data: Some(BTreeMap::from([("key".to_string(), "value".to_string())])),
..Default::default()
};
assert!(
client.apply(&cm, Some(ns)).await.is_ok(),
"first apply failed"
);
assert!(
client.apply(&cm, Some(ns)).await.is_ok(),
"second apply failed (not idempotent)"
);
let api: Api<ConfigMap> = Api::namespaced(client.client.clone(), ns);
let _ = api.delete(&name, &DeleteParams::default()).await;
}
#[tokio::test]
#[ignore = "requires kubernetes cluster"]
async fn apply_dynamic_creates_new_resource() {
let client = K8sClient::try_default().await.unwrap();
let ns = "default";
let name = format!(
"test-dyn-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis()
);
let obj = DynamicObject {
types: Some(TypeMeta {
api_version: "v1".to_string(),
kind: "ConfigMap".to_string(),
}),
metadata: ObjectMeta {
name: Some(name.clone()),
namespace: Some(ns.to_string()),
..Default::default()
},
data: serde_json::json!({}),
};
let result = client.apply_dynamic(&obj, Some(ns), false).await;
assert!(result.is_ok(), "apply_dynamic failed: {:?}", result.err());
let api: Api<ConfigMap> = Api::namespaced(client.client.clone(), ns);
let _ = api.delete(&name, &DeleteParams::default()).await;
}
}

View File

@@ -1,133 +0,0 @@
//! Resource Bundle Pattern Implementation
//!
//! This module implements the Resource Bundle pattern for managing groups of
//! Kubernetes resources that form a logical unit of work.
//!
//! ## Purpose
//!
//! The ResourceBundle pattern addresses the need to manage ephemeral privileged
//! pods along with their platform-specific security requirements (e.g., OpenShift
//! Security Context Constraints).
//!
//! ## Use Cases
//!
//! - Writing files to node filesystems (e.g., NetworkManager configurations for
//! network bonding as described in ADR-019)
//! - Running privileged commands on nodes (e.g., reboots, system configuration)
//!
//! ## Benefits
//!
//! - **Separation of Concerns**: Client code doesn't need to know about
//! platform-specific RBAC requirements
//! - **Atomic Operations**: Resources are applied and deleted as a unit
//! - **Clean Abstractions**: Privileged operations are encapsulated in bundles
//! rather than scattered throughout client methods
//!
//! ## Example
//!
//! ```
//! use harmony_k8s::{K8sClient, helper};
//! use harmony_k8s::KubernetesDistribution;
//!
//! async fn write_network_config(client: &K8sClient, node: &str) {
//! // Create a bundle with platform-specific RBAC
//! let bundle = helper::build_privileged_bundle(
//! helper::PrivilegedPodConfig {
//! name: "network-config".to_string(),
//! namespace: "default".to_string(),
//! node_name: node.to_string(),
//! // ... other config
//! ..Default::default()
//! },
//! &KubernetesDistribution::OpenshiftFamily,
//! );
//!
//! // Apply all resources (RBAC + Pod) atomically
//! bundle.apply(client).await.unwrap();
//!
//! // ... wait for completion ...
//!
//! // Cleanup all resources
//! bundle.delete(client).await.unwrap();
//! }
//! ```
use kube::{Error, Resource, ResourceExt, api::DynamicObject};
use serde::Serialize;
use serde_json;
use crate::K8sClient;
/// A ResourceBundle represents a logical unit of work consisting of multiple
/// Kubernetes resources that should be applied or deleted together.
///
/// This pattern is useful for managing ephemeral privileged pods along with
/// their required RBAC bindings (e.g., OpenShift SCC bindings).
#[derive(Debug)]
pub struct ResourceBundle {
pub resources: Vec<DynamicObject>,
}
impl ResourceBundle {
pub fn new() -> Self {
Self {
resources: Vec::new(),
}
}
/// Add a Kubernetes resource to this bundle.
/// The resource is converted to a DynamicObject for generic handling.
pub fn add<K>(&mut self, resource: K)
where
K: Resource + Serialize,
<K as Resource>::DynamicType: Default,
{
// Convert the typed resource to JSON, then to DynamicObject
let json = serde_json::to_value(&resource).expect("Failed to serialize resource");
let mut obj: DynamicObject =
serde_json::from_value(json).expect("Failed to convert to DynamicObject");
// Ensure type metadata is set
if obj.types.is_none() {
let api_version = Default::default();
let kind = Default::default();
let gvk = K::api_version(&api_version);
let kind = K::kind(&kind);
obj.types = Some(kube::api::TypeMeta {
api_version: gvk.to_string(),
kind: kind.to_string(),
});
}
self.resources.push(obj);
}
/// Apply all resources in this bundle to the cluster.
/// Resources are applied in the order they were added.
pub async fn apply(&self, client: &K8sClient) -> Result<(), Error> {
for res in &self.resources {
let namespace = res.namespace();
client
.apply_dynamic(res, namespace.as_deref(), true)
.await?;
}
Ok(())
}
/// Delete all resources in this bundle from the cluster.
/// Resources are deleted in reverse order to respect dependencies.
pub async fn delete(&self, client: &K8sClient) -> Result<(), Error> {
// FIXME delete all in parallel and retry using kube::client::retry::RetryPolicy
for res in self.resources.iter().rev() {
let api = client.get_api_for_dynamic_object(res, res.namespace().as_deref())?;
let name = res.name_any();
// FIXME this swallows all errors. Swallowing a 404 is ok but other errors must be
// handled properly (such as retrying). A normal error case is when we delete a
// resource bundle with dependencies between various resources. Such as a pod with a
// dependency on a ClusterRoleBinding. Trying to delete the ClusterRoleBinding first
// is expected to fail
let _ = api.delete(&name, &kube::api::DeleteParams::default()).await;
}
Ok(())
}
}

View File

@@ -1,99 +0,0 @@
use std::sync::Arc;
use kube::config::{KubeConfigOptions, Kubeconfig};
use kube::{Client, Config, Discovery, Error};
use log::error;
use serde::Serialize;
use tokio::sync::OnceCell;
use crate::types::KubernetesDistribution;
// TODO not cool, should use a proper configuration mechanism
// cli arg, env var, config file
fn read_dry_run_from_env() -> bool {
std::env::var("DRY_RUN")
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}
#[derive(Clone)]
pub struct K8sClient {
pub(crate) client: Client,
/// When `true` no mutation is sent to the API server; diffs are printed
/// to stdout instead. Initialised from the `DRY_RUN` environment variable.
pub(crate) dry_run: bool,
pub(crate) k8s_distribution: Arc<OnceCell<KubernetesDistribution>>,
pub(crate) discovery: Arc<OnceCell<Discovery>>,
}
impl Serialize for K8sClient {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
todo!("K8sClient serialization is not meaningful; remove this impl if unused")
}
}
impl std::fmt::Debug for K8sClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"K8sClient {{ namespace: {}, dry_run: {} }}",
self.client.default_namespace(),
self.dry_run,
))
}
}
impl K8sClient {
/// Create a client, reading `DRY_RUN` from the environment.
pub fn new(client: Client) -> Self {
Self {
dry_run: read_dry_run_from_env(),
client,
k8s_distribution: Arc::new(OnceCell::new()),
discovery: Arc::new(OnceCell::new()),
}
}
/// Create a client that always operates in dry-run mode, regardless of
/// the environment variable.
pub fn new_dry_run(client: Client) -> Self {
Self {
dry_run: true,
..Self::new(client)
}
}
/// Returns `true` if this client is operating in dry-run mode.
pub fn is_dry_run(&self) -> bool {
self.dry_run
}
pub async fn try_default() -> Result<Self, Error> {
Ok(Self::new(Client::try_default().await?))
}
pub async fn from_kubeconfig(path: &str) -> Option<Self> {
Self::from_kubeconfig_with_opts(path, &KubeConfigOptions::default()).await
}
pub async fn from_kubeconfig_with_context(path: &str, context: Option<String>) -> Option<Self> {
let mut opts = KubeConfigOptions::default();
opts.context = context;
Self::from_kubeconfig_with_opts(path, &opts).await
}
pub async fn from_kubeconfig_with_opts(path: &str, opts: &KubeConfigOptions) -> Option<Self> {
let k = match Kubeconfig::read_from(path) {
Ok(k) => k,
Err(e) => {
error!("Failed to load kubeconfig from {path}: {e}");
return None;
}
};
Some(Self::new(
Client::try_from(Config::from_custom_kubeconfig(k, opts).await.unwrap()).unwrap(),
))
}
}

View File

@@ -1 +0,0 @@
pub const PRIVILEGED_POD_IMAGE: &str = "hub.nationtech.io/redhat/ubi10:latest";

View File

@@ -1,83 +0,0 @@
use std::time::Duration;
use kube::{Discovery, Error};
use log::{debug, error, info, trace, warn};
use tokio::sync::Mutex;
use tokio_retry::{Retry, strategy::ExponentialBackoff};
use crate::client::K8sClient;
use crate::types::KubernetesDistribution;
impl K8sClient {
pub async fn get_apiserver_version(
&self,
) -> Result<k8s_openapi::apimachinery::pkg::version::Info, Error> {
self.client.clone().apiserver_version().await
}
/// Runs (and caches) Kubernetes API discovery with exponential-backoff retries.
pub async fn discovery(&self) -> Result<&Discovery, Error> {
let retry_strategy = ExponentialBackoff::from_millis(1000)
.max_delay(Duration::from_secs(32))
.take(6);
let attempt = Mutex::new(0u32);
Retry::spawn(retry_strategy, || async {
let mut n = attempt.lock().await;
*n += 1;
match self
.discovery
.get_or_try_init(async || {
debug!("Running Kubernetes API discovery (attempt {})", *n);
let d = Discovery::new(self.client.clone()).run().await?;
debug!("Kubernetes API discovery completed");
Ok(d)
})
.await
{
Ok(d) => Ok(d),
Err(e) => {
warn!("Kubernetes API discovery failed (attempt {}): {}", *n, e);
Err(e)
}
}
})
.await
.map_err(|e| {
error!("Kubernetes API discovery failed after all retries: {}", e);
e
})
}
/// Detect which Kubernetes distribution is running. Result is cached for
/// the lifetime of the client.
pub async fn get_k8s_distribution(&self) -> Result<KubernetesDistribution, Error> {
self.k8s_distribution
.get_or_try_init(async || {
debug!("Detecting Kubernetes distribution");
let api_groups = self.client.list_api_groups().await?;
trace!("list_api_groups: {:?}", api_groups);
let version = self.get_apiserver_version().await?;
if api_groups
.groups
.iter()
.any(|g| g.name == "project.openshift.io")
{
info!("Detected distribution: OpenshiftFamily");
return Ok(KubernetesDistribution::OpenshiftFamily);
}
if version.git_version.contains("k3s") {
info!("Detected distribution: K3sFamily");
return Ok(KubernetesDistribution::K3sFamily);
}
info!("Distribution not identified, using Default");
Ok(KubernetesDistribution::Default)
})
.await
.cloned()
}
}

View File

@@ -1,613 +0,0 @@
use std::collections::BTreeMap;
use std::time::Duration;
use crate::KubernetesDistribution;
use super::bundle::ResourceBundle;
use super::config::PRIVILEGED_POD_IMAGE;
use k8s_openapi::api::core::v1::{
Container, HostPathVolumeSource, Pod, PodSpec, SecurityContext, Volume, VolumeMount,
};
use k8s_openapi::api::rbac::v1::{ClusterRoleBinding, RoleRef, Subject};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use kube::api::DynamicObject;
use kube::error::DiscoveryError;
use log::{debug, error, info, warn};
use serde::de::DeserializeOwned;
#[derive(Debug)]
pub struct PrivilegedPodConfig {
pub name: String,
pub namespace: String,
pub node_name: String,
pub container_name: String,
pub command: Vec<String>,
pub volumes: Vec<Volume>,
pub volume_mounts: Vec<VolumeMount>,
pub host_pid: bool,
pub host_network: bool,
}
impl Default for PrivilegedPodConfig {
fn default() -> Self {
Self {
name: "privileged-pod".to_string(),
namespace: "harmony".to_string(),
node_name: "".to_string(),
container_name: "privileged-container".to_string(),
command: vec![],
volumes: vec![],
volume_mounts: vec![],
host_pid: false,
host_network: false,
}
}
}
pub fn build_privileged_pod(
config: PrivilegedPodConfig,
k8s_distribution: &KubernetesDistribution,
) -> Pod {
let annotations = match k8s_distribution {
KubernetesDistribution::OpenshiftFamily => Some(BTreeMap::from([
("openshift.io/scc".to_string(), "privileged".to_string()),
(
"openshift.io/required-scc".to_string(),
"privileged".to_string(),
),
])),
_ => None,
};
Pod {
metadata: ObjectMeta {
name: Some(config.name),
namespace: Some(config.namespace),
annotations,
..Default::default()
},
spec: Some(PodSpec {
node_name: Some(config.node_name),
restart_policy: Some("Never".to_string()),
host_pid: Some(config.host_pid),
host_network: Some(config.host_network),
containers: vec![Container {
name: config.container_name,
image: Some(PRIVILEGED_POD_IMAGE.to_string()),
command: Some(config.command),
security_context: Some(SecurityContext {
privileged: Some(true),
..Default::default()
}),
volume_mounts: Some(config.volume_mounts),
..Default::default()
}],
volumes: Some(config.volumes),
..Default::default()
}),
..Default::default()
}
}
pub fn host_root_volume() -> (Volume, VolumeMount) {
(
Volume {
name: "host".to_string(),
host_path: Some(HostPathVolumeSource {
path: "/".to_string(),
..Default::default()
}),
..Default::default()
},
VolumeMount {
name: "host".to_string(),
mount_path: "/host".to_string(),
..Default::default()
},
)
}
/// Build a ResourceBundle containing a privileged pod and any required RBAC.
///
/// This function implements the Resource Bundle pattern to encapsulate platform-specific
/// security requirements for running privileged operations on nodes.
///
/// # Platform-Specific Behavior
///
/// - **OpenShift**: Creates a ClusterRoleBinding to grant the default ServiceAccount
/// access to the `system:openshift:scc:privileged` ClusterRole, which allows the pod
/// to use the privileged Security Context Constraint (SCC).
/// - **Standard Kubernetes/K3s**: Only creates the Pod resource, as these distributions
/// use standard PodSecurityPolicy or don't enforce additional security constraints.
///
/// # Arguments
///
/// * `config` - Configuration for the privileged pod (name, namespace, command, etc.)
/// * `k8s_distribution` - The detected Kubernetes distribution to determine RBAC requirements
///
/// # Returns
///
/// A `ResourceBundle` containing 1-2 resources:
/// - ClusterRoleBinding (OpenShift only)
/// - Pod (all distributions)
///
/// # Example
///
/// ```
/// use harmony_k8s::helper::{build_privileged_bundle, PrivilegedPodConfig};
/// use harmony_k8s::KubernetesDistribution;
/// let bundle = build_privileged_bundle(
/// PrivilegedPodConfig {
/// name: "network-setup".to_string(),
/// namespace: "default".to_string(),
/// node_name: "worker-01".to_string(),
/// container_name: "setup".to_string(),
/// command: vec!["nmcli".to_string(), "connection".to_string(), "reload".to_string()],
/// ..Default::default()
/// },
/// &KubernetesDistribution::OpenshiftFamily,
/// );
/// // Bundle now contains ClusterRoleBinding + Pod
/// ```
pub fn build_privileged_bundle(
config: PrivilegedPodConfig,
k8s_distribution: &KubernetesDistribution,
) -> ResourceBundle {
debug!(
"Building privileged bundle for config {config:#?} on distribution {k8s_distribution:?}"
);
let mut bundle = ResourceBundle::new();
let pod_name = config.name.clone();
let namespace = config.namespace.clone();
// 1. On OpenShift, create RBAC binding to privileged SCC
if let KubernetesDistribution::OpenshiftFamily = k8s_distribution {
// The default ServiceAccount needs to be bound to the privileged SCC
// via the system:openshift:scc:privileged ClusterRole
let crb = ClusterRoleBinding {
metadata: ObjectMeta {
name: Some(format!("{}-scc-binding", pod_name)),
..Default::default()
},
role_ref: RoleRef {
api_group: "rbac.authorization.k8s.io".to_string(),
kind: "ClusterRole".to_string(),
name: "system:openshift:scc:privileged".to_string(),
},
subjects: Some(vec![Subject {
kind: "ServiceAccount".to_string(),
name: "default".to_string(),
namespace: Some(namespace.clone()),
api_group: None,
..Default::default()
}]),
};
bundle.add(crb);
}
// 2. Build the privileged pod
let pod = build_privileged_pod(config, k8s_distribution);
bundle.add(pod);
bundle
}
/// Action to take when a drain operation times out.
pub enum DrainTimeoutAction {
/// Accept the partial drain and continue
Accept,
/// Retry the drain for another timeout period
Retry,
/// Abort the drain operation
Abort,
}
/// Prompts the user to confirm acceptance of a partial drain.
///
/// Returns `Ok(true)` if the user confirms acceptance, `Ok(false)` if the user
/// chooses to retry or abort, and `Err` if the prompt system fails entirely.
pub fn prompt_drain_timeout_action(
node_name: &str,
pending_count: usize,
timeout_duration: Duration,
) -> Result<DrainTimeoutAction, kube::Error> {
let prompt_msg = format!(
"Drain operation timed out on node '{}' with {} pod(s) remaining. What would you like to do?",
node_name, pending_count
);
loop {
let choices = vec![
"Accept drain failure (requires confirmation)".to_string(),
format!("Retry drain for another {:?}", timeout_duration),
"Abort operation".to_string(),
];
let selection = inquire::Select::new(&prompt_msg, choices)
.with_help_message("Use arrow keys to navigate, Enter to select")
.prompt()
.map_err(|e| {
kube::Error::Discovery(DiscoveryError::MissingResource(format!(
"Prompt failed: {}",
e
)))
})?;
if selection.starts_with("Accept") {
// Require typed confirmation - retry until correct or user cancels
let required_confirmation = format!("yes-accept-drain:{}={}", node_name, pending_count);
let confirmation_prompt = format!(
"To accept this partial drain, type exactly: {}",
required_confirmation
);
match inquire::Text::new(&confirmation_prompt)
.with_help_message(&format!(
"This action acknowledges {} pods will remain on the node",
pending_count
))
.prompt()
{
Ok(input) if input == required_confirmation => {
warn!(
"User accepted partial drain of node '{}' with {} pods remaining (confirmation: {})",
node_name, pending_count, required_confirmation
);
return Ok(DrainTimeoutAction::Accept);
}
Ok(input) => {
warn!(
"Confirmation failed. Expected '{}', got '{}'. Please try again.",
required_confirmation, input
);
}
Err(e) => {
// User cancelled (Ctrl+C) or prompt system failed
error!("Confirmation prompt cancelled or failed: {}", e);
return Ok(DrainTimeoutAction::Abort);
}
}
} else if selection.starts_with("Retry") {
info!(
"User chose to retry drain operation for another {:?}",
timeout_duration
);
return Ok(DrainTimeoutAction::Retry);
} else {
error!("Drain operation aborted by user");
return Ok(DrainTimeoutAction::Abort);
}
}
}
/// JSON round-trip: DynamicObject → K
///
/// Safe because the DynamicObject was produced by the apiserver from a
/// payload that was originally serialized from K, so the schema is identical.
pub(crate) fn dyn_to_typed<K: DeserializeOwned>(obj: DynamicObject) -> Result<K, kube::Error> {
serde_json::to_value(obj)
.and_then(serde_json::from_value)
.map_err(kube::Error::SerdeError)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_host_root_volume() {
let (volume, mount) = host_root_volume();
assert_eq!(volume.name, "host");
assert_eq!(volume.host_path.as_ref().unwrap().path, "/");
assert_eq!(mount.name, "host");
assert_eq!(mount.mount_path, "/host");
}
#[test]
fn test_build_privileged_pod_minimal() {
let pod = build_privileged_pod(
PrivilegedPodConfig {
name: "minimal-pod".to_string(),
namespace: "kube-system".to_string(),
node_name: "node-123".to_string(),
container_name: "debug-container".to_string(),
command: vec!["sleep".to_string(), "3600".to_string()],
..Default::default()
},
&KubernetesDistribution::Default,
);
assert_eq!(pod.metadata.name, Some("minimal-pod".to_string()));
assert_eq!(pod.metadata.namespace, Some("kube-system".to_string()));
let spec = pod.spec.as_ref().expect("Pod spec should be present");
assert_eq!(spec.node_name, Some("node-123".to_string()));
assert_eq!(spec.restart_policy, Some("Never".to_string()));
assert_eq!(spec.host_pid, Some(false));
assert_eq!(spec.host_network, Some(false));
assert_eq!(spec.containers.len(), 1);
let container = &spec.containers[0];
assert_eq!(container.name, "debug-container");
assert_eq!(container.image, Some(PRIVILEGED_POD_IMAGE.to_string()));
assert_eq!(
container.command,
Some(vec!["sleep".to_string(), "3600".to_string()])
);
// Security context check
let sec_ctx = container
.security_context
.as_ref()
.expect("Security context missing");
assert_eq!(sec_ctx.privileged, Some(true));
}
#[test]
fn test_build_privileged_pod_with_volumes_and_host_access() {
let (host_vol, host_mount) = host_root_volume();
let pod = build_privileged_pod(
PrivilegedPodConfig {
name: "full-pod".to_string(),
namespace: "default".to_string(),
node_name: "node-1".to_string(),
container_name: "runner".to_string(),
command: vec!["/bin/sh".to_string()],
volumes: vec![host_vol.clone()],
volume_mounts: vec![host_mount.clone()],
host_pid: true,
host_network: true,
},
&KubernetesDistribution::Default,
);
let spec = pod.spec.as_ref().expect("Pod spec should be present");
assert_eq!(spec.host_pid, Some(true));
assert_eq!(spec.host_network, Some(true));
// Check volumes in Spec
let volumes = spec.volumes.as_ref().expect("Volumes should be present");
assert_eq!(volumes.len(), 1);
assert_eq!(volumes[0].name, "host");
// Check mounts in Container
let container = &spec.containers[0];
let mounts = container
.volume_mounts
.as_ref()
.expect("Mounts should be present");
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].name, "host");
assert_eq!(mounts[0].mount_path, "/host");
}
#[test]
fn test_build_privileged_pod_structure_correctness() {
// This test validates that the construction logic puts things in the right places
// effectively validating the "template".
let custom_vol = Volume {
name: "custom-vol".to_string(),
..Default::default()
};
let custom_mount = VolumeMount {
name: "custom-vol".to_string(),
mount_path: "/custom".to_string(),
..Default::default()
};
let pod = build_privileged_pod(
PrivilegedPodConfig {
name: "structure-test".to_string(),
namespace: "test-ns".to_string(),
node_name: "test-node".to_string(),
container_name: "test-container".to_string(),
command: vec!["cmd".to_string()],
volumes: vec![custom_vol],
volume_mounts: vec![custom_mount],
..Default::default()
},
&KubernetesDistribution::Default,
);
// Validate structure depth
let spec = pod.spec.as_ref().unwrap();
// 1. Spec level fields
assert!(spec.node_name.is_some());
assert!(spec.volumes.is_some());
// 2. Container level fields
let container = &spec.containers[0];
assert!(container.security_context.is_some());
assert!(container.volume_mounts.is_some());
// 3. Nested fields
assert!(
container
.security_context
.as_ref()
.unwrap()
.privileged
.unwrap()
);
assert_eq!(spec.volumes.as_ref().unwrap()[0].name, "custom-vol");
assert_eq!(
container.volume_mounts.as_ref().unwrap()[0].mount_path,
"/custom"
);
}
#[test]
fn test_build_privileged_bundle_default_distribution() {
let bundle = build_privileged_bundle(
PrivilegedPodConfig {
name: "test-bundle".to_string(),
namespace: "test-ns".to_string(),
node_name: "node-1".to_string(),
container_name: "test-container".to_string(),
command: vec!["echo".to_string(), "hello".to_string()],
..Default::default()
},
&KubernetesDistribution::Default,
);
// For Default distribution, only the Pod should be in the bundle
assert_eq!(bundle.resources.len(), 1);
let pod_obj = &bundle.resources[0];
assert_eq!(pod_obj.metadata.name.as_deref(), Some("test-bundle"));
assert_eq!(pod_obj.metadata.namespace.as_deref(), Some("test-ns"));
}
#[test]
fn test_build_privileged_bundle_openshift_distribution() {
let bundle = build_privileged_bundle(
PrivilegedPodConfig {
name: "test-bundle-ocp".to_string(),
namespace: "test-ns".to_string(),
node_name: "node-1".to_string(),
container_name: "test-container".to_string(),
command: vec!["echo".to_string(), "hello".to_string()],
..Default::default()
},
&KubernetesDistribution::OpenshiftFamily,
);
// For OpenShift, both ClusterRoleBinding and Pod should be in the bundle
assert_eq!(bundle.resources.len(), 2);
// First resource should be the ClusterRoleBinding
let crb_obj = &bundle.resources[0];
assert_eq!(
crb_obj.metadata.name.as_deref(),
Some("test-bundle-ocp-scc-binding")
);
// Verify it's targeting the privileged SCC
if let Some(role_ref) = crb_obj.data.get("roleRef") {
assert_eq!(
role_ref.get("name").and_then(|v| v.as_str()),
Some("system:openshift:scc:privileged")
);
}
// Second resource should be the Pod
let pod_obj = &bundle.resources[1];
assert_eq!(pod_obj.metadata.name.as_deref(), Some("test-bundle-ocp"));
assert_eq!(pod_obj.metadata.namespace.as_deref(), Some("test-ns"));
}
#[test]
fn test_build_privileged_bundle_k3s_distribution() {
let bundle = build_privileged_bundle(
PrivilegedPodConfig {
name: "test-bundle-k3s".to_string(),
namespace: "test-ns".to_string(),
node_name: "node-1".to_string(),
container_name: "test-container".to_string(),
command: vec!["echo".to_string(), "hello".to_string()],
..Default::default()
},
&KubernetesDistribution::K3sFamily,
);
// For K3s, only the Pod should be in the bundle (no special SCC)
assert_eq!(bundle.resources.len(), 1);
let pod_obj = &bundle.resources[0];
assert_eq!(pod_obj.metadata.name.as_deref(), Some("test-bundle-k3s"));
}
#[test]
fn test_pod_yaml_rendering_expected() {
let pod = build_privileged_pod(
PrivilegedPodConfig {
name: "pod_name".to_string(),
namespace: "pod_namespace".to_string(),
node_name: "node name".to_string(),
container_name: "container name".to_string(),
command: vec!["command".to_string(), "argument".to_string()],
host_pid: true,
host_network: true,
..Default::default()
},
&KubernetesDistribution::Default,
);
assert_eq!(
&serde_yaml::to_string(&pod).unwrap(),
"apiVersion: v1
kind: Pod
metadata:
name: pod_name
namespace: pod_namespace
spec:
containers:
- command:
- command
- argument
image: hub.nationtech.io/redhat/ubi10:latest
name: container name
securityContext:
privileged: true
volumeMounts: []
hostNetwork: true
hostPID: true
nodeName: node name
restartPolicy: Never
volumes: []
"
);
}
#[test]
fn test_pod_yaml_rendering_openshift() {
let pod = build_privileged_pod(
PrivilegedPodConfig {
name: "pod_name".to_string(),
namespace: "pod_namespace".to_string(),
node_name: "node name".to_string(),
container_name: "container name".to_string(),
command: vec!["command".to_string(), "argument".to_string()],
host_pid: true,
host_network: true,
..Default::default()
},
&KubernetesDistribution::OpenshiftFamily,
);
assert_eq!(
&serde_yaml::to_string(&pod).unwrap(),
"apiVersion: v1
kind: Pod
metadata:
annotations:
openshift.io/required-scc: privileged
openshift.io/scc: privileged
name: pod_name
namespace: pod_namespace
spec:
containers:
- command:
- command
- argument
image: hub.nationtech.io/redhat/ubi10:latest
name: container name
securityContext:
privileged: true
volumeMounts: []
hostNetwork: true
hostPID: true
nodeName: node name
restartPolicy: Never
volumes: []
"
);
}
}

View File

@@ -1,13 +0,0 @@
pub mod apply;
pub mod bundle;
pub mod client;
pub mod config;
pub mod discovery;
pub mod helper;
pub mod node;
pub mod pod;
pub mod resources;
pub mod types;
pub use client::K8sClient;
pub use types::{DrainOptions, KubernetesDistribution, NodeFile, ScopeResolver, WriteMode};

View File

@@ -1,3 +0,0 @@
fn main() {
println!("Hello, world!");
}

View File

@@ -1,722 +0,0 @@
use std::collections::BTreeMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use k8s_openapi::api::core::v1::{
ConfigMap, ConfigMapVolumeSource, Node, Pod, Volume, VolumeMount,
};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use kube::{
Error,
api::{Api, DeleteParams, EvictParams, ListParams, PostParams},
core::ErrorResponse,
error::DiscoveryError,
};
use log::{debug, error, info, warn};
use tokio::time::sleep;
use crate::client::K8sClient;
use crate::helper::{self, PrivilegedPodConfig};
use crate::types::{DrainOptions, NodeFile};
impl K8sClient {
pub async fn cordon_node(&self, node_name: &str) -> Result<(), Error> {
Api::<Node>::all(self.client.clone())
.cordon(node_name)
.await?;
Ok(())
}
pub async fn uncordon_node(&self, node_name: &str) -> Result<(), Error> {
Api::<Node>::all(self.client.clone())
.uncordon(node_name)
.await?;
Ok(())
}
pub async fn wait_for_node_ready(&self, node_name: &str) -> Result<(), Error> {
self.wait_for_node_ready_with_timeout(node_name, Duration::from_secs(600))
.await
}
async fn wait_for_node_ready_with_timeout(
&self,
node_name: &str,
timeout: Duration,
) -> Result<(), Error> {
let api: Api<Node> = Api::all(self.client.clone());
let start = tokio::time::Instant::now();
let poll = Duration::from_secs(5);
loop {
if start.elapsed() > timeout {
return Err(Error::Discovery(DiscoveryError::MissingResource(format!(
"Node '{node_name}' did not become Ready within {timeout:?}"
))));
}
match api.get(node_name).await {
Ok(node) => {
if node
.status
.as_ref()
.and_then(|s| s.conditions.as_ref())
.map(|conds| {
conds
.iter()
.any(|c| c.type_ == "Ready" && c.status == "True")
})
.unwrap_or(false)
{
debug!("Node '{node_name}' is Ready");
return Ok(());
}
}
Err(e) => debug!("Error polling node '{node_name}': {e}"),
}
sleep(poll).await;
}
}
async fn wait_for_node_not_ready(
&self,
node_name: &str,
timeout: Duration,
) -> Result<(), Error> {
let api: Api<Node> = Api::all(self.client.clone());
let start = tokio::time::Instant::now();
let poll = Duration::from_secs(5);
loop {
if start.elapsed() > timeout {
return Err(Error::Discovery(DiscoveryError::MissingResource(format!(
"Node '{node_name}' did not become NotReady within {timeout:?}"
))));
}
match api.get(node_name).await {
Ok(node) => {
let is_ready = node
.status
.as_ref()
.and_then(|s| s.conditions.as_ref())
.map(|conds| {
conds
.iter()
.any(|c| c.type_ == "Ready" && c.status == "True")
})
.unwrap_or(false);
if !is_ready {
debug!("Node '{node_name}' is NotReady");
return Ok(());
}
}
Err(e) => debug!("Error polling node '{node_name}': {e}"),
}
sleep(poll).await;
}
}
async fn list_pods_on_node(&self, node_name: &str) -> Result<Vec<Pod>, Error> {
let api: Api<Pod> = Api::all(self.client.clone());
Ok(api
.list(&ListParams::default().fields(&format!("spec.nodeName={node_name}")))
.await?
.items)
}
fn is_mirror_pod(pod: &Pod) -> bool {
pod.metadata
.annotations
.as_ref()
.map(|a| a.contains_key("kubernetes.io/config.mirror"))
.unwrap_or(false)
}
fn is_daemonset_pod(pod: &Pod) -> bool {
pod.metadata
.owner_references
.as_ref()
.map(|refs| refs.iter().any(|r| r.kind == "DaemonSet"))
.unwrap_or(false)
}
fn has_emptydir_volume(pod: &Pod) -> bool {
pod.spec
.as_ref()
.and_then(|s| s.volumes.as_ref())
.map(|vols| vols.iter().any(|v| v.empty_dir.is_some()))
.unwrap_or(false)
}
fn is_completed_pod(pod: &Pod) -> bool {
pod.status
.as_ref()
.and_then(|s| s.phase.as_deref())
.map(|phase| phase == "Succeeded" || phase == "Failed")
.unwrap_or(false)
}
fn classify_pods_for_drain(
pods: &[Pod],
options: &DrainOptions,
) -> Result<(Vec<Pod>, Vec<String>), String> {
let mut evictable = Vec::new();
let mut skipped = Vec::new();
let mut blocking = Vec::new();
for pod in pods {
let name = pod.metadata.name.as_deref().unwrap_or("<unknown>");
let ns = pod.metadata.namespace.as_deref().unwrap_or("<unknown>");
let qualified = format!("{ns}/{name}");
if Self::is_mirror_pod(pod) {
skipped.push(format!("{qualified} (mirror pod)"));
continue;
}
if Self::is_completed_pod(pod) {
skipped.push(format!("{qualified} (completed)"));
continue;
}
if Self::is_daemonset_pod(pod) {
if options.ignore_daemonsets {
skipped.push(format!("{qualified} (DaemonSet-managed)"));
} else {
blocking.push(format!(
"{qualified} is managed by a DaemonSet (set ignore_daemonsets to skip)"
));
}
continue;
}
if Self::has_emptydir_volume(pod) && !options.delete_emptydir_data {
blocking.push(format!(
"{qualified} uses emptyDir volumes (set delete_emptydir_data to allow eviction)"
));
continue;
}
evictable.push(pod.clone());
}
if !blocking.is_empty() {
return Err(format!(
"Cannot drain node — the following pods block eviction:\n - {}",
blocking.join("\n - ")
));
}
Ok((evictable, skipped))
}
async fn evict_pod(&self, pod: &Pod) -> Result<(), Error> {
let name = pod.metadata.name.as_deref().unwrap_or_default();
let ns = pod.metadata.namespace.as_deref().unwrap_or_default();
debug!("Evicting pod {ns}/{name}");
Api::<Pod>::namespaced(self.client.clone(), ns)
.evict(name, &EvictParams::default())
.await
.map(|_| ())
}
/// Drains a node: cordon → classify → evict & wait.
pub async fn drain_node(&self, node_name: &str, options: &DrainOptions) -> Result<(), Error> {
debug!("Cordoning '{node_name}'");
self.cordon_node(node_name).await?;
let pods = self.list_pods_on_node(node_name).await?;
debug!("Found {} pod(s) on '{node_name}'", pods.len());
let (evictable, skipped) =
Self::classify_pods_for_drain(&pods, options).map_err(|msg| {
error!("{msg}");
Error::Discovery(DiscoveryError::MissingResource(msg))
})?;
for s in &skipped {
info!("Skipping pod: {s}");
}
if evictable.is_empty() {
info!("No pods to evict on '{node_name}'");
return Ok(());
}
info!("Evicting {} pod(s) from '{node_name}'", evictable.len());
let mut start = tokio::time::Instant::now();
let poll = Duration::from_secs(5);
let mut pending = evictable;
loop {
for pod in &pending {
match self.evict_pod(pod).await {
Ok(()) => {}
Err(Error::Api(ErrorResponse { code: 404, .. })) => {}
Err(Error::Api(ErrorResponse { code: 429, .. })) => {
warn!(
"PDB blocked eviction of {}/{}; will retry",
pod.metadata.namespace.as_deref().unwrap_or(""),
pod.metadata.name.as_deref().unwrap_or("")
);
}
Err(e) => {
error!(
"Failed to evict {}/{}: {e}",
pod.metadata.namespace.as_deref().unwrap_or(""),
pod.metadata.name.as_deref().unwrap_or("")
);
return Err(e);
}
}
}
sleep(poll).await;
let mut still_present = Vec::new();
for pod in pending {
let ns = pod.metadata.namespace.as_deref().unwrap_or_default();
let name = pod.metadata.name.as_deref().unwrap_or_default();
match self.get_pod(name, Some(ns)).await? {
Some(_) => still_present.push(pod),
None => debug!("Pod {ns}/{name} evicted"),
}
}
pending = still_present;
if pending.is_empty() {
break;
}
if start.elapsed() > options.timeout {
match helper::prompt_drain_timeout_action(
node_name,
pending.len(),
options.timeout,
)? {
helper::DrainTimeoutAction::Accept => break,
helper::DrainTimeoutAction::Retry => {
start = tokio::time::Instant::now();
continue;
}
helper::DrainTimeoutAction::Abort => {
return Err(Error::Discovery(DiscoveryError::MissingResource(format!(
"Drain aborted. {} pod(s) remaining on '{node_name}'",
pending.len()
))));
}
}
}
debug!("Waiting for {} pod(s) on '{node_name}'", pending.len());
}
debug!("'{node_name}' drained successfully");
Ok(())
}
/// Safely reboots a node: drain → reboot → wait for Ready → uncordon.
pub async fn reboot_node(
&self,
node_name: &str,
drain_options: &DrainOptions,
timeout: Duration,
) -> Result<(), Error> {
info!("Starting reboot for '{node_name}'");
let node_api: Api<Node> = Api::all(self.client.clone());
let boot_id_before = node_api
.get(node_name)
.await?
.status
.as_ref()
.and_then(|s| s.node_info.as_ref())
.map(|ni| ni.boot_id.clone())
.ok_or_else(|| {
Error::Discovery(DiscoveryError::MissingResource(format!(
"Node '{node_name}' has no boot_id in status"
)))
})?;
info!("Draining '{node_name}'");
self.drain_node(node_name, drain_options).await?;
let start = tokio::time::Instant::now();
info!("Scheduling reboot for '{node_name}'");
let reboot_cmd =
"echo rebooting ; nohup bash -c 'sleep 5 && nsenter -t 1 -m -- systemctl reboot'";
match self
.run_privileged_command_on_node(node_name, reboot_cmd)
.await
{
Ok(_) => debug!("Reboot command dispatched"),
Err(e) => debug!("Reboot command error (expected if node began shutdown): {e}"),
}
info!("Waiting for '{node_name}' to begin shutdown");
self.wait_for_node_not_ready(node_name, timeout.saturating_sub(start.elapsed()))
.await?;
if start.elapsed() > timeout {
return Err(Error::Discovery(DiscoveryError::MissingResource(format!(
"Timeout during reboot of '{node_name}' (shutdown phase)"
))));
}
info!("Waiting for '{node_name}' to come back online");
self.wait_for_node_ready_with_timeout(node_name, timeout.saturating_sub(start.elapsed()))
.await?;
if start.elapsed() > timeout {
return Err(Error::Discovery(DiscoveryError::MissingResource(format!(
"Timeout during reboot of '{node_name}' (ready phase)"
))));
}
let boot_id_after = node_api
.get(node_name)
.await?
.status
.as_ref()
.and_then(|s| s.node_info.as_ref())
.map(|ni| ni.boot_id.clone())
.ok_or_else(|| {
Error::Discovery(DiscoveryError::MissingResource(format!(
"Node '{node_name}' has no boot_id after reboot"
)))
})?;
if boot_id_before == boot_id_after {
return Err(Error::Discovery(DiscoveryError::MissingResource(format!(
"Node '{node_name}' did not actually reboot (boot_id unchanged: {boot_id_before})"
))));
}
info!("'{node_name}' rebooted ({boot_id_before} → {boot_id_after})");
self.uncordon_node(node_name).await?;
info!("'{node_name}' reboot complete ({:?})", start.elapsed());
Ok(())
}
/// Write a set of files to a node's filesystem via a privileged ephemeral pod.
pub async fn write_files_to_node(
&self,
node_name: &str,
files: &[NodeFile],
) -> Result<String, Error> {
let ns = self.client.default_namespace();
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let name = format!("harmony-k8s-writer-{suffix}");
debug!("Writing {} file(s) to '{node_name}'", files.len());
let mut data = BTreeMap::new();
let mut script = String::from("set -e\n");
for (i, file) in files.iter().enumerate() {
let key = format!("f{i}");
data.insert(key.clone(), file.content.clone());
script.push_str(&format!("mkdir -p \"$(dirname \"/host{}\")\"\n", file.path));
script.push_str(&format!("cp \"/payload/{key}\" \"/host{}\"\n", file.path));
script.push_str(&format!("chmod {:o} \"/host{}\"\n", file.mode, file.path));
}
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some(name.clone()),
namespace: Some(ns.to_string()),
..Default::default()
},
data: Some(data),
..Default::default()
};
let cm_api: Api<ConfigMap> = Api::namespaced(self.client.clone(), ns);
cm_api.create(&PostParams::default(), &cm).await?;
debug!("Created ConfigMap '{name}'");
let (host_vol, host_mount) = helper::host_root_volume();
let payload_vol = Volume {
name: "payload".to_string(),
config_map: Some(ConfigMapVolumeSource {
name: name.clone(),
..Default::default()
}),
..Default::default()
};
let payload_mount = VolumeMount {
name: "payload".to_string(),
mount_path: "/payload".to_string(),
..Default::default()
};
let bundle = helper::build_privileged_bundle(
PrivilegedPodConfig {
name: name.clone(),
namespace: ns.to_string(),
node_name: node_name.to_string(),
container_name: "writer".to_string(),
command: vec!["/bin/bash".to_string(), "-c".to_string(), script],
volumes: vec![payload_vol, host_vol],
volume_mounts: vec![payload_mount, host_mount],
host_pid: false,
host_network: false,
},
&self.get_k8s_distribution().await?,
);
bundle.apply(self).await?;
debug!("Created privileged pod bundle '{name}'");
let result = self.wait_for_pod_completion(&name, ns).await;
debug!("Cleaning up '{name}'");
let _ = bundle.delete(self).await;
let _ = cm_api.delete(&name, &DeleteParams::default()).await;
result
}
/// Run a privileged command on a node via an ephemeral pod.
pub async fn run_privileged_command_on_node(
&self,
node_name: &str,
command: &str,
) -> Result<String, Error> {
let namespace = self.client.default_namespace();
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let name = format!("harmony-k8s-cmd-{suffix}");
debug!("Running privileged command on '{node_name}': {command}");
let (host_vol, host_mount) = helper::host_root_volume();
let bundle = helper::build_privileged_bundle(
PrivilegedPodConfig {
name: name.clone(),
namespace: namespace.to_string(),
node_name: node_name.to_string(),
container_name: "runner".to_string(),
command: vec![
"/bin/bash".to_string(),
"-c".to_string(),
command.to_string(),
],
volumes: vec![host_vol],
volume_mounts: vec![host_mount],
host_pid: true,
host_network: true,
},
&self.get_k8s_distribution().await?,
);
bundle.apply(self).await?;
debug!("Privileged pod '{name}' created");
let result = self.wait_for_pod_completion(&name, namespace).await;
debug!("Cleaning up '{name}'");
let _ = bundle.delete(self).await;
result
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use k8s_openapi::api::core::v1::{EmptyDirVolumeSource, PodSpec, PodStatus, Volume};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, OwnerReference};
use super::*;
fn base_pod(name: &str, ns: &str) -> Pod {
Pod {
metadata: ObjectMeta {
name: Some(name.to_string()),
namespace: Some(ns.to_string()),
..Default::default()
},
spec: Some(PodSpec::default()),
status: Some(PodStatus {
phase: Some("Running".to_string()),
..Default::default()
}),
}
}
fn mirror_pod(name: &str, ns: &str) -> Pod {
let mut pod = base_pod(name, ns);
pod.metadata.annotations = Some(std::collections::BTreeMap::from([(
"kubernetes.io/config.mirror".to_string(),
"abc123".to_string(),
)]));
pod
}
fn daemonset_pod(name: &str, ns: &str) -> Pod {
let mut pod = base_pod(name, ns);
pod.metadata.owner_references = Some(vec![OwnerReference {
api_version: "apps/v1".to_string(),
kind: "DaemonSet".to_string(),
name: "some-ds".to_string(),
uid: "uid-ds".to_string(),
..Default::default()
}]);
pod
}
fn emptydir_pod(name: &str, ns: &str) -> Pod {
let mut pod = base_pod(name, ns);
pod.spec = Some(PodSpec {
volumes: Some(vec![Volume {
name: "scratch".to_string(),
empty_dir: Some(EmptyDirVolumeSource::default()),
..Default::default()
}]),
..Default::default()
});
pod
}
fn completed_pod(name: &str, ns: &str, phase: &str) -> Pod {
let mut pod = base_pod(name, ns);
pod.status = Some(PodStatus {
phase: Some(phase.to_string()),
..Default::default()
});
pod
}
fn default_opts() -> DrainOptions {
DrainOptions::default()
}
// All test bodies are identical to the original — only the module path changed.
#[test]
fn empty_pod_list_returns_empty_vecs() {
let (e, s) = K8sClient::classify_pods_for_drain(&[], &default_opts()).unwrap();
assert!(e.is_empty());
assert!(s.is_empty());
}
#[test]
fn normal_pod_is_evictable() {
let pods = vec![base_pod("web", "default")];
let (e, s) = K8sClient::classify_pods_for_drain(&pods, &default_opts()).unwrap();
assert_eq!(e.len(), 1);
assert!(s.is_empty());
}
#[test]
fn mirror_pod_is_skipped() {
let pods = vec![mirror_pod("kube-apiserver", "kube-system")];
let (e, s) = K8sClient::classify_pods_for_drain(&pods, &default_opts()).unwrap();
assert!(e.is_empty());
assert!(s[0].contains("mirror pod"));
}
#[test]
fn completed_pods_are_skipped() {
for phase in ["Succeeded", "Failed"] {
let pods = vec![completed_pod("job", "batch", phase)];
let (e, s) = K8sClient::classify_pods_for_drain(&pods, &default_opts()).unwrap();
assert!(e.is_empty());
assert!(s[0].contains("completed"));
}
}
#[test]
fn daemonset_skipped_when_ignored() {
let pods = vec![daemonset_pod("fluentd", "logging")];
let opts = DrainOptions {
ignore_daemonsets: true,
..default_opts()
};
let (e, s) = K8sClient::classify_pods_for_drain(&pods, &opts).unwrap();
assert!(e.is_empty());
assert!(s[0].contains("DaemonSet-managed"));
}
#[test]
fn daemonset_blocks_when_not_ignored() {
let pods = vec![daemonset_pod("fluentd", "logging")];
let opts = DrainOptions {
ignore_daemonsets: false,
..default_opts()
};
let err = K8sClient::classify_pods_for_drain(&pods, &opts).unwrap_err();
assert!(err.contains("DaemonSet") && err.contains("logging/fluentd"));
}
#[test]
fn emptydir_blocks_without_flag() {
let pods = vec![emptydir_pod("cache", "default")];
let opts = DrainOptions {
delete_emptydir_data: false,
..default_opts()
};
let err = K8sClient::classify_pods_for_drain(&pods, &opts).unwrap_err();
assert!(err.contains("emptyDir") && err.contains("default/cache"));
}
#[test]
fn emptydir_evictable_with_flag() {
let pods = vec![emptydir_pod("cache", "default")];
let opts = DrainOptions {
delete_emptydir_data: true,
..default_opts()
};
let (e, s) = K8sClient::classify_pods_for_drain(&pods, &opts).unwrap();
assert_eq!(e.len(), 1);
assert!(s.is_empty());
}
#[test]
fn multiple_blocking_all_reported() {
let pods = vec![daemonset_pod("ds", "ns1"), emptydir_pod("ed", "ns2")];
let opts = DrainOptions {
ignore_daemonsets: false,
delete_emptydir_data: false,
..default_opts()
};
let err = K8sClient::classify_pods_for_drain(&pods, &opts).unwrap_err();
assert!(err.contains("ns1/ds") && err.contains("ns2/ed"));
}
#[test]
fn mixed_pods_classified_correctly() {
let pods = vec![
base_pod("web", "default"),
mirror_pod("kube-apiserver", "kube-system"),
daemonset_pod("fluentd", "logging"),
completed_pod("job", "batch", "Succeeded"),
base_pod("api", "default"),
];
let (e, s) = K8sClient::classify_pods_for_drain(&pods, &default_opts()).unwrap();
let names: Vec<&str> = e
.iter()
.map(|p| p.metadata.name.as_deref().unwrap())
.collect();
assert_eq!(names, vec!["web", "api"]);
assert_eq!(s.len(), 3);
}
#[test]
fn mirror_checked_before_completed() {
let mut pod = mirror_pod("static-etcd", "kube-system");
pod.status = Some(PodStatus {
phase: Some("Succeeded".to_string()),
..Default::default()
});
let (_, s) = K8sClient::classify_pods_for_drain(&[pod], &default_opts()).unwrap();
assert!(s[0].contains("mirror pod"), "got: {}", s[0]);
}
#[test]
fn completed_checked_before_daemonset() {
let mut pod = daemonset_pod("collector", "monitoring");
pod.status = Some(PodStatus {
phase: Some("Failed".to_string()),
..Default::default()
});
let (_, s) = K8sClient::classify_pods_for_drain(&[pod], &default_opts()).unwrap();
assert!(s[0].contains("completed"), "got: {}", s[0]);
}
}

View File

@@ -1,193 +0,0 @@
use std::time::Duration;
use k8s_openapi::api::core::v1::Pod;
use kube::{
Error,
api::{Api, AttachParams, ListParams},
error::DiscoveryError,
runtime::reflector::Lookup,
};
use log::debug;
use tokio::io::AsyncReadExt;
use tokio::time::sleep;
use crate::client::K8sClient;
impl K8sClient {
pub async fn get_pod(&self, name: &str, namespace: Option<&str>) -> Result<Option<Pod>, Error> {
let api: Api<Pod> = match namespace {
Some(ns) => Api::namespaced(self.client.clone(), ns),
None => Api::default_namespaced(self.client.clone()),
};
api.get_opt(name).await
}
pub async fn wait_for_pod_ready(
&self,
pod_name: &str,
namespace: Option<&str>,
) -> Result<(), Error> {
let mut elapsed = 0u64;
let interval = 5u64;
let timeout_secs = 120u64;
loop {
if let Some(p) = self.get_pod(pod_name, namespace).await? {
if let Some(phase) = p.status.and_then(|s| s.phase) {
if phase.to_lowercase() == "running" {
return Ok(());
}
}
}
if elapsed >= timeout_secs {
return Err(Error::Discovery(DiscoveryError::MissingResource(format!(
"Pod '{}' in '{}' did not become ready within {timeout_secs}s",
pod_name,
namespace.unwrap_or("<default>"),
))));
}
sleep(Duration::from_secs(interval)).await;
elapsed += interval;
}
}
/// Polls a pod until it reaches `Succeeded` or `Failed`, then returns its
/// logs. Used internally by node operations.
pub(crate) async fn wait_for_pod_completion(
&self,
name: &str,
namespace: &str,
) -> Result<String, Error> {
let api: Api<Pod> = Api::namespaced(self.client.clone(), namespace);
let poll_interval = Duration::from_secs(2);
for _ in 0..60 {
sleep(poll_interval).await;
let p = api.get(name).await?;
match p.status.and_then(|s| s.phase).as_deref() {
Some("Succeeded") => {
let logs = api
.logs(name, &Default::default())
.await
.unwrap_or_default();
debug!("Pod {namespace}/{name} succeeded. Logs: {logs}");
return Ok(logs);
}
Some("Failed") => {
let logs = api
.logs(name, &Default::default())
.await
.unwrap_or_default();
debug!("Pod {namespace}/{name} failed. Logs: {logs}");
return Err(Error::Discovery(DiscoveryError::MissingResource(format!(
"Pod '{name}' failed.\n{logs}"
))));
}
_ => {}
}
}
Err(Error::Discovery(DiscoveryError::MissingResource(format!(
"Timed out waiting for pod '{name}'"
))))
}
/// Execute a command in the first pod matching `{label}={name}`.
pub async fn exec_app_capture_output(
&self,
name: String,
label: String,
namespace: Option<&str>,
command: Vec<&str>,
) -> Result<String, String> {
let api: Api<Pod> = match namespace {
Some(ns) => Api::namespaced(self.client.clone(), ns),
None => Api::default_namespaced(self.client.clone()),
};
let pod_list = api
.list(&ListParams::default().labels(&format!("{label}={name}")))
.await
.expect("Failed to list pods");
let pod_name = pod_list
.items
.first()
.expect("No matching pod")
.name()
.expect("Pod has no name")
.into_owned();
match api
.exec(
&pod_name,
command,
&AttachParams::default().stdout(true).stderr(true),
)
.await
{
Err(e) => Err(e.to_string()),
Ok(mut process) => {
let status = process
.take_status()
.expect("No status handle")
.await
.expect("Status channel closed");
if let Some(s) = status.status {
let mut buf = String::new();
if let Some(mut stdout) = process.stdout() {
stdout
.read_to_string(&mut buf)
.await
.map_err(|e| format!("Failed to read stdout: {e}"))?;
}
debug!("exec status: {} - {:?}", s, status.details);
if s == "Success" { Ok(buf) } else { Err(s) }
} else {
Err("No inner status from pod exec".to_string())
}
}
}
}
/// Execute a command in the first pod matching
/// `app.kubernetes.io/name={name}`.
pub async fn exec_app(
&self,
name: String,
namespace: Option<&str>,
command: Vec<&str>,
) -> Result<(), String> {
let api: Api<Pod> = match namespace {
Some(ns) => Api::namespaced(self.client.clone(), ns),
None => Api::default_namespaced(self.client.clone()),
};
let pod_list = api
.list(&ListParams::default().labels(&format!("app.kubernetes.io/name={name}")))
.await
.expect("Failed to list pods");
let pod_name = pod_list
.items
.first()
.expect("No matching pod")
.name()
.expect("Pod has no name")
.into_owned();
match api.exec(&pod_name, command, &AttachParams::default()).await {
Err(e) => Err(e.to_string()),
Ok(mut process) => {
let status = process
.take_status()
.expect("No status handle")
.await
.expect("Status channel closed");
if let Some(s) = status.status {
debug!("exec status: {} - {:?}", s, status.details);
if s == "Success" { Ok(()) } else { Err(s) }
} else {
Err("No inner status from pod exec".to_string())
}
}
}
}
}

View File

@@ -1,316 +0,0 @@
use std::collections::HashMap;
use k8s_openapi::api::{
apps::v1::Deployment,
core::v1::{Node, ServiceAccount},
};
use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition;
use kube::api::ApiResource;
use kube::{
Error, Resource,
api::{Api, DynamicObject, GroupVersionKind, ListParams, ObjectList},
runtime::conditions,
runtime::wait::await_condition,
};
use log::debug;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::time::Duration;
use crate::client::K8sClient;
use crate::types::ScopeResolver;
impl K8sClient {
pub async fn has_healthy_deployment_with_label(
&self,
namespace: &str,
label_selector: &str,
) -> Result<bool, Error> {
let api: Api<Deployment> = Api::namespaced(self.client.clone(), namespace);
let list = api
.list(&ListParams::default().labels(label_selector))
.await?;
for d in list.items {
let available = d
.status
.as_ref()
.and_then(|s| s.available_replicas)
.unwrap_or(0);
if available > 0 {
return Ok(true);
}
if let Some(conds) = d.status.as_ref().and_then(|s| s.conditions.as_ref()) {
if conds
.iter()
.any(|c| c.type_ == "Available" && c.status == "True")
{
return Ok(true);
}
}
}
Ok(false)
}
pub async fn list_namespaces_with_healthy_deployments(
&self,
label_selector: &str,
) -> Result<Vec<String>, Error> {
let api: Api<Deployment> = Api::all(self.client.clone());
let list = api
.list(&ListParams::default().labels(label_selector))
.await?;
let mut healthy_ns: HashMap<String, bool> = HashMap::new();
for d in list.items {
let ns = match d.metadata.namespace.clone() {
Some(n) => n,
None => continue,
};
let available = d
.status
.as_ref()
.and_then(|s| s.available_replicas)
.unwrap_or(0);
let is_healthy = if available > 0 {
true
} else {
d.status
.as_ref()
.and_then(|s| s.conditions.as_ref())
.map(|c| {
c.iter()
.any(|c| c.type_ == "Available" && c.status == "True")
})
.unwrap_or(false)
};
if is_healthy {
healthy_ns.insert(ns, true);
}
}
Ok(healthy_ns.into_keys().collect())
}
pub async fn get_controller_service_account_name(
&self,
ns: &str,
) -> Result<Option<String>, Error> {
let api: Api<Deployment> = Api::namespaced(self.client.clone(), ns);
let list = api
.list(&ListParams::default().labels("app.kubernetes.io/component=controller"))
.await?;
if let Some(dep) = list.items.first() {
if let Some(sa) = dep
.spec
.as_ref()
.and_then(|s| s.template.spec.as_ref())
.and_then(|s| s.service_account_name.clone())
{
return Ok(Some(sa));
}
}
Ok(None)
}
pub async fn list_clusterrolebindings_json(&self) -> Result<Vec<Value>, Error> {
let gvk = GroupVersionKind::gvk("rbac.authorization.k8s.io", "v1", "ClusterRoleBinding");
let ar = ApiResource::from_gvk(&gvk);
let api: Api<DynamicObject> = Api::all_with(self.client.clone(), &ar);
let list = api.list(&ListParams::default()).await?;
Ok(list
.items
.into_iter()
.map(|o| serde_json::to_value(&o).unwrap_or(Value::Null))
.collect())
}
pub async fn is_service_account_cluster_wide(&self, sa: &str, ns: &str) -> Result<bool, Error> {
let sa_user = format!("system:serviceaccount:{ns}:{sa}");
for crb in self.list_clusterrolebindings_json().await? {
if let Some(subjects) = crb.get("subjects").and_then(|s| s.as_array()) {
for subj in subjects {
let kind = subj.get("kind").and_then(|v| v.as_str()).unwrap_or("");
let name = subj.get("name").and_then(|v| v.as_str()).unwrap_or("");
let subj_ns = subj.get("namespace").and_then(|v| v.as_str()).unwrap_or("");
if (kind == "ServiceAccount" && name == sa && subj_ns == ns)
|| (kind == "User" && name == sa_user)
{
return Ok(true);
}
}
}
}
Ok(false)
}
pub async fn has_crd(&self, name: &str) -> Result<bool, Error> {
let api: Api<CustomResourceDefinition> = Api::all(self.client.clone());
let crds = api
.list(&ListParams::default().fields(&format!("metadata.name={name}")))
.await?;
Ok(!crds.items.is_empty())
}
pub async fn service_account_api(&self, namespace: &str) -> Api<ServiceAccount> {
Api::namespaced(self.client.clone(), namespace)
}
pub async fn get_resource_json_value(
&self,
name: &str,
namespace: Option<&str>,
gvk: &GroupVersionKind,
) -> Result<DynamicObject, Error> {
let ar = ApiResource::from_gvk(gvk);
let api: Api<DynamicObject> = match namespace {
Some(ns) => Api::namespaced_with(self.client.clone(), ns, &ar),
None => Api::default_namespaced_with(self.client.clone(), &ar),
};
api.get(name).await
}
pub async fn get_secret_json_value(
&self,
name: &str,
namespace: Option<&str>,
) -> Result<DynamicObject, Error> {
self.get_resource_json_value(
name,
namespace,
&GroupVersionKind {
group: String::new(),
version: "v1".to_string(),
kind: "Secret".to_string(),
},
)
.await
}
pub async fn get_deployment(
&self,
name: &str,
namespace: Option<&str>,
) -> Result<Option<Deployment>, Error> {
let api: Api<Deployment> = match namespace {
Some(ns) => {
debug!("Getting namespaced deployment '{name}' in '{ns}'");
Api::namespaced(self.client.clone(), ns)
}
None => {
debug!("Getting deployment '{name}' in default namespace");
Api::default_namespaced(self.client.clone())
}
};
api.get_opt(name).await
}
pub async fn scale_deployment(
&self,
name: &str,
namespace: Option<&str>,
replicas: u32,
) -> Result<(), Error> {
let api: Api<Deployment> = match namespace {
Some(ns) => Api::namespaced(self.client.clone(), ns),
None => Api::default_namespaced(self.client.clone()),
};
use kube::api::{Patch, PatchParams};
use serde_json::json;
let patch = json!({ "spec": { "replicas": replicas } });
api.patch_scale(name, &PatchParams::default(), &Patch::Merge(&patch))
.await?;
Ok(())
}
pub async fn delete_deployment(
&self,
name: &str,
namespace: Option<&str>,
) -> Result<(), Error> {
let api: Api<Deployment> = match namespace {
Some(ns) => Api::namespaced(self.client.clone(), ns),
None => Api::default_namespaced(self.client.clone()),
};
api.delete(name, &kube::api::DeleteParams::default())
.await?;
Ok(())
}
pub async fn wait_until_deployment_ready(
&self,
name: &str,
namespace: Option<&str>,
timeout: Option<Duration>,
) -> Result<(), String> {
let api: Api<Deployment> = match namespace {
Some(ns) => Api::namespaced(self.client.clone(), ns),
None => Api::default_namespaced(self.client.clone()),
};
let timeout = timeout.unwrap_or(Duration::from_secs(120));
let establish = await_condition(api, name, conditions::is_deployment_completed());
tokio::time::timeout(timeout, establish)
.await
.map(|_| ())
.map_err(|_| "Timed out waiting for deployment".to_string())
}
/// Gets a single named resource, using the correct API scope for `K`.
pub async fn get_resource<K>(
&self,
name: &str,
namespace: Option<&str>,
) -> Result<Option<K>, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned,
<K as Resource>::Scope: ScopeResolver<K>,
<K as Resource>::DynamicType: Default,
{
let api: Api<K> =
<<K as Resource>::Scope as ScopeResolver<K>>::get_api(&self.client, namespace);
api.get_opt(name).await
}
pub async fn list_resources<K>(
&self,
namespace: Option<&str>,
list_params: Option<ListParams>,
) -> Result<ObjectList<K>, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned,
<K as Resource>::Scope: ScopeResolver<K>,
<K as Resource>::DynamicType: Default,
{
let api: Api<K> =
<<K as Resource>::Scope as ScopeResolver<K>>::get_api(&self.client, namespace);
api.list(&list_params.unwrap_or_default()).await
}
pub async fn list_all_resources_with_labels<K>(&self, labels: &str) -> Result<Vec<K>, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned,
<K as Resource>::DynamicType: Default,
{
Api::<K>::all(self.client.clone())
.list(&ListParams::default().labels(labels))
.await
.map(|l| l.items)
}
pub async fn get_all_resource_in_all_namespace<K>(&self) -> Result<Vec<K>, Error>
where
K: Resource + Clone + std::fmt::Debug + DeserializeOwned,
<K as Resource>::Scope: ScopeResolver<K>,
<K as Resource>::DynamicType: Default,
{
Api::<K>::all(self.client.clone())
.list(&Default::default())
.await
.map(|l| l.items)
}
pub async fn get_nodes(
&self,
list_params: Option<ListParams>,
) -> Result<ObjectList<Node>, Error> {
self.list_resources(None, list_params).await
}
}

View File

@@ -1,100 +0,0 @@
use std::time::Duration;
use k8s_openapi::{ClusterResourceScope, NamespaceResourceScope};
use kube::{Api, Client, Resource};
use serde::Serialize;
/// Which Kubernetes distribution is running. Detected once at runtime via
/// [`crate::discovery::K8sClient::get_k8s_distribution`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum KubernetesDistribution {
Default,
OpenshiftFamily,
K3sFamily,
}
/// A file to be written to a node's filesystem.
#[derive(Debug, Clone)]
pub struct NodeFile {
/// Absolute path on the host where the file should be written.
pub path: String,
/// Content of the file.
pub content: String,
/// UNIX permissions (e.g. `0o600`).
pub mode: u32,
}
/// Options controlling the behaviour of a [`crate::K8sClient::drain_node`] operation.
#[derive(Debug, Clone)]
pub struct DrainOptions {
/// Evict pods that use `emptyDir` volumes (ephemeral data is lost).
/// Equivalent to `kubectl drain --delete-emptydir-data`.
pub delete_emptydir_data: bool,
/// Silently skip DaemonSet-managed pods instead of blocking the drain.
/// Equivalent to `kubectl drain --ignore-daemonsets`.
pub ignore_daemonsets: bool,
/// Maximum wall-clock time to wait for all evictions to complete.
pub timeout: Duration,
}
impl Default for DrainOptions {
fn default() -> Self {
Self {
delete_emptydir_data: false,
ignore_daemonsets: true,
timeout: Duration::from_secs(1),
}
}
}
impl DrainOptions {
pub fn default_ignore_daemonset_delete_emptydir_data() -> Self {
Self {
delete_emptydir_data: true,
ignore_daemonsets: true,
..Self::default()
}
}
}
/// Controls how [`crate::K8sClient::apply_with_strategy`] behaves when the
/// resource already exists (or does not).
pub enum WriteMode {
/// Server-side apply; create if absent, update if present (default).
CreateOrUpdate,
/// POST only; return an error if the resource already exists.
Create,
/// Server-side apply only; return an error if the resource does not exist.
Update,
}
// ── Scope resolution trait ───────────────────────────────────────────────────
/// Resolves the correct [`kube::Api`] for a resource type based on its scope
/// (cluster-wide vs. namespace-scoped).
pub trait ScopeResolver<K: Resource> {
fn get_api(client: &Client, ns: Option<&str>) -> Api<K>;
}
impl<K> ScopeResolver<K> for ClusterResourceScope
where
K: Resource<Scope = ClusterResourceScope>,
<K as Resource>::DynamicType: Default,
{
fn get_api(client: &Client, _ns: Option<&str>) -> Api<K> {
Api::all(client.clone())
}
}
impl<K> ScopeResolver<K> for NamespaceResourceScope
where
K: Resource<Scope = NamespaceResourceScope>,
<K as Resource>::DynamicType: Default,
{
fn get_api(client: &Client, ns: Option<&str>) -> Api<K> {
match ns {
Some(ns) => Api::namespaced(client.clone(), ns),
None => Api::default_namespaced(client.clone()),
}
}
}

View File

@@ -21,8 +21,6 @@ semver = "1.0.23"
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-retry.workspace = true
tokio-util.workspace = true
derive-new.workspace = true
log.workspace = true
env_logger.workspace = true
@@ -33,7 +31,6 @@ opnsense-config-xml = { path = "../opnsense-config-xml" }
harmony_macros = { path = "../harmony_macros" }
harmony_types = { path = "../harmony_types" }
harmony_execution = { path = "../harmony_execution" }
harmony-k8s = { path = "../harmony-k8s" }
uuid.workspace = true
url.workspace = true
kube = { workspace = true, features = ["derive"] }
@@ -63,6 +60,7 @@ temp-dir = "0.1.14"
dyn-clone = "1.0.19"
similar.workspace = true
futures-util = "0.3.31"
tokio-util = "0.7.15"
strum = { version = "0.27.1", features = ["derive"] }
tempfile.workspace = true
serde_with = "3.14.0"
@@ -82,7 +80,7 @@ sqlx.workspace = true
inquire.workspace = true
brocade = { path = "../brocade" }
option-ext = "0.2.0"
rand.workspace = true
tokio-retry = "0.3.0"
[dev-dependencies]
pretty_assertions.workspace = true

View File

@@ -108,18 +108,11 @@ impl PhysicalHost {
};
let storage_summary = if drive_count > 1 {
let drive_sizes = self
.storage
.iter()
.map(|d| format_storage(d.size_bytes))
.collect::<Vec<_>>()
.join(", ");
format!(
"{} Storage ({} Disks [{}])",
"{} Storage ({}x {})",
format_storage(total_storage_bytes),
drive_count,
drive_sizes
first_drive_model
)
} else {
format!(

View File

@@ -4,6 +4,8 @@ use std::error::Error;
use async_trait::async_trait;
use derive_new::new;
use crate::inventory::HostRole;
use super::{
data::Version, executors::ExecutorError, inventory::Inventory, topology::PreparationError,
};

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use harmony_k8s::K8sClient;
use brocade::PortOperatingMode;
use harmony_macros::ip;
use harmony_types::{
id::Id,
@@ -9,20 +9,17 @@ use harmony_types::{
use log::debug;
use log::info;
use crate::topology::{HelmCommand, PxeOptions};
use crate::{data::FileContent, executors::ExecutorError, topology::node_exporter::NodeExporter};
use crate::{infra::network_manager::OpenShiftNmStateNetworkManager, topology::PortConfig};
use crate::{modules::inventory::HarmonyDiscoveryStrategy, topology::PxeOptions};
use super::{
DHCPStaticEntry, DhcpServer, DnsRecord, DnsRecordType, DnsServer, Firewall, HostNetworkConfig,
HttpServer, IpAddress, K8sclient, LoadBalancer, LoadBalancerService, LogicalHost, NetworkError,
NetworkManager, PreparationError, PreparationOutcome, Router, Switch, SwitchClient,
SwitchError, TftpServer, Topology,
};
use std::{
process::Command,
sync::{Arc, OnceLock},
SwitchError, TftpServer, Topology, k8s::K8sClient,
};
use std::sync::{Arc, OnceLock};
#[derive(Debug, Clone)]
pub struct HAClusterTopology {
@@ -56,30 +53,6 @@ impl Topology for HAClusterTopology {
}
}
impl HelmCommand for HAClusterTopology {
fn get_helm_command(&self) -> Command {
let mut cmd = Command::new("helm");
if let Some(k) = &self.kubeconfig {
cmd.args(["--kubeconfig", k]);
}
// FIXME we should support context anywhere there is a k8sclient
// This likely belongs in the k8sclient itself and should be extracted to a separate
// crate
//
// I feel like helm could very well be a feature of this external k8s client.
//
// Same for kustomize
//
// if let Some(c) = &self.k8s_context {
// cmd.args(["--kube-context", c]);
// }
info!("Using helm command {cmd:?}");
cmd
}
}
#[async_trait]
impl K8sclient for HAClusterTopology {
async fn k8s_client(&self) -> Result<Arc<K8sClient>, String> {
@@ -328,10 +301,10 @@ impl Switch for HAClusterTopology {
Ok(())
}
async fn clear_port_channel(&self, _ids: &Vec<Id>) -> Result<(), SwitchError> {
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError> {
todo!()
}
async fn configure_interface(&self, _ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
async fn configure_interface(&self, ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
todo!()
}
}
@@ -349,15 +322,7 @@ impl NetworkManager for HAClusterTopology {
self.network_manager().await.configure_bond(config).await
}
async fn configure_bond_on_primary_interface(
&self,
config: &HostNetworkConfig,
) -> Result<(), NetworkError> {
self.network_manager()
.await
.configure_bond_on_primary_interface(config)
.await
}
//TODO add snmp here
}
#[async_trait]
@@ -597,10 +562,10 @@ impl SwitchClient for DummyInfra {
) -> Result<u8, SwitchError> {
unimplemented!("{}", UNIMPLEMENTED_DUMMY_INFRA)
}
async fn clear_port_channel(&self, _ids: &Vec<Id>) -> Result<(), SwitchError> {
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError> {
todo!()
}
async fn configure_interface(&self, _ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
async fn configure_interface(&self, ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
todo!()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,18 @@ use std::{collections::BTreeMap, process::Command, sync::Arc, time::Duration};
use async_trait::async_trait;
use base64::{Engine, engine::general_purpose};
use harmony_k8s::{K8sClient, KubernetesDistribution};
use harmony_types::rfc1123::Rfc1123Name;
use k8s_openapi::api::{
use k8s_openapi::{
ByteString,
api::{
core::v1::{Pod, Secret},
rbac::v1::{ClusterRoleBinding, RoleRef, Subject},
},
};
use kube::{
api::{DynamicObject, GroupVersionKind, ObjectMeta},
runtime::conditions,
};
use kube::api::{DynamicObject, GroupVersionKind, ObjectMeta};
use log::{debug, info, trace, warn};
use serde::Serialize;
use tokio::sync::OnceCell;
@@ -29,7 +34,10 @@ use crate::{
score_cert_management::CertificateManagementScore,
},
k3d::K3DInstallationScore,
k8s::ingress::{K8sIngressScore, PathType},
k8s::{
ingress::{K8sIngressScore, PathType},
resource::K8sResourceScore,
},
monitoring::{
grafana::{grafana::Grafana, helm::helm_grafana::grafana_helm_chart_score},
kube_prometheus::crd::{
@@ -46,6 +54,7 @@ use crate::{
service_monitor::ServiceMonitor,
},
},
nats::capability::NatsCluster,
okd::{crd::ingresses_config::Ingress as IngressResource, route::OKDTlsPassthroughScore},
prometheus::{
k8s_prometheus_alerting_score::K8sPrometheusCRDAlertingScore,
@@ -59,6 +68,7 @@ use crate::{
use super::super::{
DeploymentTarget, HelmCommand, K8sclient, MultiTargetTopology, PreparationError,
PreparationOutcome, Topology,
k8s::K8sClient,
oberservability::monitoring::AlertReceiver,
tenant::{
TenantConfig, TenantManager,
@@ -76,6 +86,13 @@ struct K8sState {
message: String,
}
#[derive(Debug, Clone, Serialize)]
pub enum KubernetesDistribution {
OpenshiftFamily,
K3sFamily,
Default,
}
#[derive(Debug, Clone)]
enum K8sSource {
LocalK3d,
@@ -86,6 +103,7 @@ enum K8sSource {
pub struct K8sAnywhereTopology {
k8s_state: Arc<OnceCell<Option<K8sState>>>,
tenant_manager: Arc<OnceCell<K8sTenantManager>>,
k8s_distribution: Arc<OnceCell<KubernetesDistribution>>,
config: Arc<K8sAnywhereConfig>,
}
@@ -133,7 +151,14 @@ impl TlsRouter for K8sAnywhereTopology {
}
}
KubernetesDistribution::K3sFamily => todo!(),
KubernetesDistribution::Default => todo!(),
KubernetesDistribution::Default => {
warn!(
"unimpleneted see example https://kubernetes.github.io/ingress-nginx/user-guide/cli-arguments/ and create ingress manually "
);
Ok(Some(
"This section is in todo and must be created manually".to_string(),
))
}
}
}
@@ -536,6 +561,7 @@ impl K8sAnywhereTopology {
Self {
k8s_state: Arc::new(OnceCell::new()),
tenant_manager: Arc::new(OnceCell::new()),
k8s_distribution: Arc::new(OnceCell::new()),
config: Arc::new(K8sAnywhereConfig::from_env()),
}
}
@@ -544,6 +570,7 @@ impl K8sAnywhereTopology {
Self {
k8s_state: Arc::new(OnceCell::new()),
tenant_manager: Arc::new(OnceCell::new()),
k8s_distribution: Arc::new(OnceCell::new()),
config: Arc::new(config),
}
}
@@ -580,6 +607,41 @@ impl K8sAnywhereTopology {
}
}
pub async fn get_k8s_distribution(&self) -> Result<&KubernetesDistribution, PreparationError> {
self.k8s_distribution
.get_or_try_init(async || {
debug!("Trying to detect k8s distribution");
let client = self.k8s_client().await.unwrap();
let discovery = client.discovery().await.map_err(|e| {
PreparationError::new(format!("Could not discover API groups: {}", e))
})?;
let version = client.get_apiserver_version().await.map_err(|e| {
PreparationError::new(format!("Could not get server version: {}", e))
})?;
// OpenShift / OKD
if discovery
.groups()
.any(|g| g.name() == "project.openshift.io")
{
info!("Found KubernetesDistribution OpenshiftFamily");
return Ok(KubernetesDistribution::OpenshiftFamily);
}
// K3d / K3s
if version.git_version.contains("k3s") {
info!("Found KubernetesDistribution K3sFamily");
return Ok(KubernetesDistribution::K3sFamily);
}
info!("Could not identify KubernetesDistribution, using Default");
return Ok(KubernetesDistribution::Default);
})
.await
}
fn extract_and_normalize_token(&self, secret: &DynamicObject) -> Option<String> {
let token_b64 = secret
.data
@@ -597,16 +659,6 @@ impl K8sAnywhereTopology {
Some(cleaned)
}
pub async fn get_k8s_distribution(&self) -> Result<KubernetesDistribution, PreparationError> {
self.k8s_client()
.await?
.get_k8s_distribution()
.await
.map_err(|e| {
PreparationError::new(format!("Failed to get k8s distribution from client : {e}"))
})
}
pub fn build_cluster_rolebinding(
&self,
service_account_name: &str,

View File

@@ -3,8 +3,9 @@ use async_trait::async_trait;
use crate::{
inventory::Inventory,
modules::nats::{
capability::{Nats, NatsCluster},
score_nats_k8s::NatsK8sScore,
capability::{
JetstreamConfig, JetstreamUserConfig, Nats, NatsCluster, NatsCredentials, NatsJetstream,
}, score_nats_enable_jetstream::NatsK8sEnableJetstreamScore, score_nats_jetstream::NatsK8sJetstreamScore, score_nats_jetstream_credentials::NatsK8sJetstreamCredentialsScore, score_nats_k8s::NatsK8sScore
},
score::Score,
topology::K8sAnywhereTopology,
@@ -36,3 +37,31 @@ impl Nats for K8sAnywhereTopology {
))
}
}
#[async_trait]
impl NatsJetstream for K8sAnywhereTopology {
async fn enable_jetstream(&self, jetstream_config: &JetstreamConfig) -> Result<String, String> {
NatsK8sEnableJetstreamScore {}
.interpret(&Inventory::empty(), self)
.await
.map_err(|e| format!("Failed to enable jetstream: {}", e))?;
Ok("Nats jetstream enabled".to_string())
}
async fn create_jetstream_credentials(
&self,
jetstream_user_config: &JetstreamUserConfig,
) -> Result<String, String> {
NatsK8sJetstreamCredentialsScore {}
.interpret(&Inventory::empty(), self)
.await
.map_err(|e| format!("Failed to create credentials jetstream: {}", e))?;
Ok("Nats jetstream user credentials configured".to_string())
}
async fn get_jetstream_credentials(&self) -> Result<NatsCredentials, String> {
todo!()
}
}

View File

@@ -1,6 +1,7 @@
use async_trait::async_trait;
use crate::{
interpret::Outcome,
inventory::Inventory,
modules::postgresql::{
K8sPostgreSQLScore,

View File

@@ -1,6 +1,7 @@
use std::{net::SocketAddr, str::FromStr};
use async_trait::async_trait;
use log::debug;
use serde::Serialize;
use super::LogicalHost;
@@ -106,7 +107,6 @@ pub enum SSL {
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum HealthCheck {
/// HTTP(None, "/healthz/ready", HttpMethod::GET, HttpStatusCode::Success2xx, SSL::Disabled)
HTTP(Option<u16>, String, HttpMethod, HttpStatusCode, SSL),
HTTP(String, HttpMethod, HttpStatusCode, SSL),
TCP(Option<u16>),
}

View File

@@ -16,6 +16,7 @@ pub mod tenant;
use derive_new::new;
pub use k8s_anywhere::*;
pub use localhost::*;
pub mod k8s;
mod load_balancer;
pub mod router;
mod tftp;

View File

@@ -9,7 +9,6 @@ use std::{
use async_trait::async_trait;
use brocade::PortOperatingMode;
use derive_new::new;
use harmony_k8s::K8sClient;
use harmony_types::{
id::Id,
net::{IpAddress, MacAddress},
@@ -19,7 +18,7 @@ use serde::Serialize;
use crate::executors::ExecutorError;
use super::LogicalHost;
use super::{LogicalHost, k8s::K8sClient};
#[derive(Debug)]
pub struct DHCPStaticEntry {
@@ -189,10 +188,6 @@ impl FromStr for DnsRecordType {
pub trait NetworkManager: Debug + Send + Sync {
async fn ensure_network_manager_installed(&self) -> Result<(), NetworkError>;
async fn configure_bond(&self, config: &HostNetworkConfig) -> Result<(), NetworkError>;
async fn configure_bond_on_primary_interface(
&self,
config: &HostNetworkConfig,
) -> Result<(), NetworkError>;
}
#[derive(Debug, Clone, new)]

View File

@@ -1,8 +1,10 @@
use std::sync::Arc;
use crate::executors::ExecutorError;
use crate::{
executors::ExecutorError,
topology::k8s::{ApplyStrategy, K8sClient},
};
use async_trait::async_trait;
use harmony_k8s::K8sClient;
use k8s_openapi::{
api::{
core::v1::{LimitRange, Namespace, ResourceQuota},
@@ -12,7 +14,7 @@ use k8s_openapi::{
},
apimachinery::pkg::util::intstr::IntOrString,
};
use kube::Resource;
use kube::{Resource, api::DynamicObject};
use log::debug;
use serde::de::DeserializeOwned;
use serde_json::json;
@@ -57,6 +59,7 @@ impl K8sTenantManager {
) -> Result<K, ExecutorError>
where
<K as kube::Resource>::DynamicType: Default,
<K as kube::Resource>::Scope: ApplyStrategy<K>,
{
self.apply_labels(&mut resource, config);
self.k8s_client

View File

@@ -5,20 +5,9 @@ use harmony_types::{
net::{IpAddress, MacAddress},
switch::{PortDeclaration, PortLocation},
};
use log::info;
use option_ext::OptionExt;
use crate::{
modules::brocade::BrocadeSwitchAuth,
topology::{PortConfig, SwitchClient, SwitchError},
};
#[derive(Debug, Clone)]
pub struct BrocadeSwitchConfig {
pub ips: Vec<IpAddress>,
pub auth: BrocadeSwitchAuth,
pub options: BrocadeOptions,
}
use crate::topology::{PortConfig, SwitchClient, SwitchError};
#[derive(Debug)]
pub struct BrocadeSwitchClient {
@@ -26,11 +15,13 @@ pub struct BrocadeSwitchClient {
}
impl BrocadeSwitchClient {
pub async fn init(config: BrocadeSwitchConfig) -> Result<Self, brocade::Error> {
let auth = &config.auth;
let options = &config.options;
let brocade = brocade::init(&config.ips, &auth.username, &auth.password, options).await?;
pub async fn init(
ip_addresses: &[IpAddress],
username: &str,
password: &str,
options: BrocadeOptions,
) -> Result<Self, brocade::Error> {
let brocade = brocade::init(ip_addresses, username, password, options).await?;
Ok(Self { brocade })
}
}
@@ -61,18 +52,13 @@ impl SwitchClient for BrocadeSwitchClient {
|| link.remote_port.contains(&interface.port_location)
})
})
.map(|interface| (interface.name.clone(), PortOperatingMode::Trunk))
.map(|interface| (interface.name.clone(), PortOperatingMode::Access))
.collect();
if interfaces.is_empty() {
return Ok(());
}
info!("About to configure interfaces {interfaces:?}");
// inquire::Confirm::new("Do you wish to configures interfaces now?")
// .prompt()
// .map_err(|e| SwitchError::new(e.to_string()))?;
self.brocade
.configure_interfaces(&interfaces)
.await
@@ -222,8 +208,8 @@ mod tests {
//TODO not sure about this
let configured_interfaces = brocade.configured_interfaces.lock().unwrap();
assert_that!(*configured_interfaces).contains_exactly(vec![
(first_interface.name.clone(), PortOperatingMode::Trunk),
(second_interface.name.clone(), PortOperatingMode::Trunk),
(first_interface.name.clone(), PortOperatingMode::Access),
(second_interface.name.clone(), PortOperatingMode::Access),
]);
}

View File

@@ -3,77 +3,20 @@ use std::{
sync::Arc,
};
use askama::Template;
use async_trait::async_trait;
use harmony_k8s::{DrainOptions, K8sClient, NodeFile};
use harmony_types::id::Id;
use k8s_openapi::api::core::v1::Node;
use kube::{
ResourceExt,
api::{ObjectList, ObjectMeta},
};
use log::{debug, info, warn};
use log::{debug, info};
use crate::{
modules::okd::crd::nmstate,
topology::{HostNetworkConfig, NetworkError, NetworkManager},
topology::{HostNetworkConfig, NetworkError, NetworkManager, k8s::K8sClient},
};
/// NetworkManager bond configuration template
#[derive(Template)]
#[template(
source = r#"[connection]
id={{ bond_name }}
uuid={{ bond_uuid }}
type=bond
autoconnect-slaves=1
interface-name={{ bond_name }}
[bond]
lacp_rate=fast
mode=802.3ad
xmit_hash_policy=layer2
[ipv4]
method=auto
[ipv6]
addr-gen-mode=default
method=auto
[proxy]
"#,
ext = "txt"
)]
struct BondConfigTemplate {
bond_name: String,
bond_uuid: String,
}
/// NetworkManager bond slave configuration template
#[derive(Template)]
#[template(
source = r#"[connection]
id={{ slave_id }}
uuid={{ slave_uuid }}
type=ethernet
interface-name={{ interface_name }}
master={{ bond_name }}
slave-type=bond
[ethernet]
[bond-port]
"#,
ext = "txt"
)]
struct BondSlaveConfigTemplate {
slave_id: String,
slave_uuid: String,
interface_name: String,
bond_name: String,
}
/// TODO document properly the non-intuitive behavior or "roll forward only" of nmstate in general
/// It is documented in nmstate official doc, but worth mentionning here :
///
@@ -144,117 +87,6 @@ impl NetworkManager for OpenShiftNmStateNetworkManager {
Ok(())
}
/// Configures bonding on the primary network interface of a node.
///
/// Changing the *primary* network interface (making it a bond
/// slave) will disrupt node connectivity mid-change, so the
/// procedure is:
///
/// 1. Generate NetworkManager .nmconnection files
/// 2. Drain the node (includes cordon)
/// 3. Write configuration files to `/etc/NetworkManager/system-connections/`
/// 4. Attempt to reload NetworkManager (optional, best-effort)
/// 5. Reboot the node with full verification (drain, boot_id check, uncordon)
///
/// The reboot procedure includes:
/// - Recording boot_id before reboot
/// - Fire-and-forget reboot command
/// - Waiting for NotReady status
/// - Waiting for Ready status
/// - Verifying boot_id changed
/// - Uncordoning the node
///
/// See ADR-019 for context and rationale.
async fn configure_bond_on_primary_interface(
&self,
config: &HostNetworkConfig,
) -> Result<(), NetworkError> {
use std::time::Duration;
let node_name = self.get_node_name_for_id(&config.host_id).await?;
let hostname = self.get_hostname(&config.host_id).await?;
info!(
"Configuring bond on primary interface for host '{}' (node '{}')",
config.host_id, node_name
);
// 1. Generate .nmconnection files
let files = self.generate_nmconnection_files(&hostname, config)?;
debug!(
"Generated {} NetworkManager configuration files",
files.len()
);
// 2. Write configuration files to the node (before draining)
// We do this while the node is still running for faster operation
info!(
"Writing NetworkManager configuration files to node '{}'...",
node_name
);
self.k8s_client
.write_files_to_node(&node_name, &files)
.await
.map_err(|e| {
NetworkError::new(format!(
"Failed to write configuration files to node '{}': {}",
node_name, e
))
})?;
// 3. Reload NetworkManager configuration (best-effort)
// This won't activate the bond yet since the primary interface would lose connectivity,
// but it validates the configuration files are correct
info!(
"Reloading NetworkManager configuration on node '{}'...",
node_name
);
match self
.k8s_client
.run_privileged_command_on_node(&node_name, "chroot /host nmcli connection reload")
.await
{
Ok(output) => {
debug!("NetworkManager reload output: {}", output.trim());
}
Err(e) => {
warn!(
"Failed to reload NetworkManager configuration: {}. Proceeding with reboot.",
e
);
// Don't fail here - reboot will pick up the config anyway
}
}
// 4. Reboot the node with full verification
// The reboot_node function handles: drain, boot_id capture, reboot, NotReady wait,
// Ready wait, boot_id verification, and uncordon
// 60 minutes timeout for bare-metal environments (drain can take 20-30 mins)
let reboot_timeout = Duration::from_secs(3600);
info!(
"Rebooting node '{}' to apply network configuration (timeout: {:?})...",
node_name, reboot_timeout
);
self.k8s_client
.reboot_node(
&node_name,
&DrainOptions::default_ignore_daemonset_delete_emptydir_data(),
reboot_timeout,
)
.await
.map_err(|e| {
NetworkError::new(format!("Failed to reboot node '{}': {}", node_name, e))
})?;
info!(
"Successfully configured bond on primary interface for host '{}' (node '{}')",
config.host_id, node_name
);
Ok(())
}
async fn configure_bond(&self, config: &HostNetworkConfig) -> Result<(), NetworkError> {
let hostname = self.get_hostname(&config.host_id).await.map_err(|e| {
NetworkError::new(format!(
@@ -376,14 +208,14 @@ impl OpenShiftNmStateNetworkManager {
}
}
async fn get_node_for_id(&self, host_id: &Id) -> Result<Node, String> {
async fn get_hostname(&self, host_id: &Id) -> Result<String, String> {
let nodes: ObjectList<Node> = self
.k8s_client
.list_resources(None, None)
.await
.map_err(|e| format!("Failed to list nodes: {e}"))?;
let Some(node) = nodes.into_iter().find(|n| {
let Some(node) = nodes.iter().find(|n| {
n.status
.as_ref()
.and_then(|s| s.node_info.as_ref())
@@ -393,20 +225,6 @@ impl OpenShiftNmStateNetworkManager {
return Err(format!("No node found for host '{host_id}'"));
};
Ok(node)
}
async fn get_node_name_for_id(&self, host_id: &Id) -> Result<String, String> {
let node = self.get_node_for_id(host_id).await?;
node.metadata.name.ok_or(format!(
"A node should always have a name, node for host_id {host_id} has no name"
))
}
async fn get_hostname(&self, host_id: &Id) -> Result<String, String> {
let node = self.get_node_for_id(host_id).await?;
node.labels()
.get("kubernetes.io/hostname")
.ok_or(format!(
@@ -443,82 +261,4 @@ impl OpenShiftNmStateNetworkManager {
let next_id = (0..).find(|id| !used_ids.contains(id)).unwrap();
Ok(format!("bond{next_id}"))
}
/// Generates NetworkManager .nmconnection files for bonding configuration.
///
/// Creates:
/// - One bond master configuration file (bond0.nmconnection)
/// - One slave configuration file per interface (bond0-<iface>.nmconnection)
///
/// All files are placed in `/etc/NetworkManager/system-connections/` with
/// mode 0o600 (required by NetworkManager).
fn generate_nmconnection_files(
&self,
hostname: &str,
config: &HostNetworkConfig,
) -> Result<Vec<NodeFile>, NetworkError> {
let mut files = Vec::new();
let bond_name = "bond0";
let bond_uuid = uuid::Uuid::new_v4().to_string();
// Generate bond master configuration
let bond_template = BondConfigTemplate {
bond_name: bond_name.to_string(),
bond_uuid: bond_uuid.clone(),
};
let bond_content = bond_template.render().map_err(|e| {
NetworkError::new(format!(
"Failed to render bond configuration template: {}",
e
))
})?;
files.push(NodeFile {
path: format!(
"/etc/NetworkManager/system-connections/{}.nmconnection",
bond_name
),
content: bond_content,
mode: 0o600,
});
// Generate slave configurations for each interface
for switch_port in &config.switch_ports {
let interface_name = &switch_port.interface.name;
let slave_id = format!("{}-{}", bond_name, interface_name);
let slave_uuid = uuid::Uuid::new_v4().to_string();
let slave_template = BondSlaveConfigTemplate {
slave_id: slave_id.clone(),
slave_uuid,
interface_name: interface_name.clone(),
bond_name: bond_name.to_string(),
};
let slave_content = slave_template.render().map_err(|e| {
NetworkError::new(format!(
"Failed to render slave configuration template for interface '{}': {}",
interface_name, e
))
})?;
files.push(NodeFile {
path: format!(
"/etc/NetworkManager/system-connections/{}.nmconnection",
slave_id
),
content: slave_content,
mode: 0o600,
});
}
debug!(
"Generated {} NetworkManager configuration files for host '{}'",
files.len(),
hostname
);
Ok(files)
}
}

View File

@@ -216,15 +216,7 @@ pub(crate) fn get_health_check_for_backend(
SSL::Other(other.to_string())
}
};
let port = haproxy_health_check
.checkport
.content_string()
.parse::<u16>()
.ok();
debug!("Found haproxy healthcheck port {port:?}");
Some(HealthCheck::HTTP(port, path, method, status_code, ssl))
Some(HealthCheck::HTTP(path, method, status_code, ssl))
}
_ => panic!("Received unsupported health check type {}", uppercase),
}
@@ -259,7 +251,7 @@ pub(crate) fn harmony_load_balancer_service_to_haproxy_xml(
// frontend points to backend
let healthcheck = if let Some(health_check) = &service.health_check {
match health_check {
HealthCheck::HTTP(port, path, http_method, _http_status_code, ssl) => {
HealthCheck::HTTP(path, http_method, _http_status_code, ssl) => {
let ssl: MaybeString = match ssl {
SSL::SSL => "ssl".into(),
SSL::SNI => "sslni".into(),
@@ -275,7 +267,6 @@ pub(crate) fn harmony_load_balancer_service_to_haproxy_xml(
http_uri: path.clone().into(),
interval: "2s".to_string(),
ssl,
checkport: MaybeString::from(port.map(|p| p.to_string())),
..Default::default()
};

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use log::{debug, info};
use log::{debug, info, trace};
use serde::Serialize;
use std::path::PathBuf;

View File

@@ -1,5 +1,4 @@
use async_trait::async_trait;
use harmony_k8s::K8sClient;
use harmony_macros::hurl;
use log::{debug, info, trace, warn};
use non_blank_string_rs::NonBlankString;
@@ -15,7 +14,7 @@ use crate::{
helm::chart::{HelmChartScore, HelmRepository},
},
score::Score,
topology::{HelmCommand, K8sclient, Topology, ingress::Ingress},
topology::{HelmCommand, K8sclient, Topology, ingress::Ingress, k8s::K8sClient},
};
use harmony_types::id::Id;

View File

@@ -1,9 +1,8 @@
use std::sync::Arc;
use harmony_k8s::K8sClient;
use log::{debug, info};
use crate::interpret::InterpretError;
use crate::{interpret::InterpretError, topology::k8s::K8sClient};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ArgoScope {

View File

@@ -39,22 +39,16 @@ pub struct BrocadeEnableSnmpInterpret {
}
#[derive(Secret, Clone, Debug, JsonSchema, Serialize, Deserialize)]
pub struct BrocadeSwitchAuth {
pub username: String,
pub password: String,
}
impl BrocadeSwitchAuth {
pub fn user_pass(username: String, password: String) -> Self {
Self { username, password }
}
struct BrocadeSwitchAuth {
username: String,
password: String,
}
#[derive(Secret, Clone, Debug, JsonSchema, Serialize, Deserialize)]
pub struct BrocadeSnmpAuth {
pub username: String,
pub auth_password: String,
pub des_password: String,
struct BrocadeSnmpAuth {
username: String,
auth_password: String,
des_password: String,
}
#[async_trait]
@@ -78,7 +72,7 @@ impl<T: Topology> Interpret<T> for BrocadeEnableSnmpInterpret {
&switch_addresses,
&config.username,
&config.password,
&BrocadeOptions {
BrocadeOptions {
dry_run: self.score.dry_run,
..Default::default()
},

View File

@@ -1,138 +0,0 @@
use async_trait::async_trait;
use brocade::{BrocadeOptions, PortOperatingMode};
use crate::{
data::Version,
infra::brocade::{BrocadeSwitchClient, BrocadeSwitchConfig},
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
inventory::Inventory,
score::Score,
topology::{
HostNetworkConfig, PortConfig, PreparationError, PreparationOutcome, Switch, SwitchClient,
SwitchError, Topology,
},
};
use harmony_macros::ip;
use harmony_types::{id::Id, net::MacAddress, switch::PortLocation};
use log::{debug, info};
use serde::Serialize;
#[derive(Clone, Debug, Serialize)]
pub struct BrocadeSwitchScore {
pub port_channels_to_clear: Vec<Id>,
pub ports_to_configure: Vec<PortConfig>,
}
impl<T: Topology + Switch> Score<T> for BrocadeSwitchScore {
fn name(&self) -> String {
"BrocadeSwitchScore".to_string()
}
#[doc(hidden)]
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(BrocadeSwitchInterpret {
score: self.clone(),
})
}
}
#[derive(Debug)]
pub struct BrocadeSwitchInterpret {
score: BrocadeSwitchScore,
}
#[async_trait]
impl<T: Topology + Switch> Interpret<T> for BrocadeSwitchInterpret {
async fn execute(
&self,
_inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
info!("Applying switch configuration {:?}", self.score);
debug!(
"Clearing port channel {:?}",
self.score.port_channels_to_clear
);
topology
.clear_port_channel(&self.score.port_channels_to_clear)
.await
.map_err(|e| InterpretError::new(e.to_string()))?;
debug!("Configuring interfaces {:?}", self.score.ports_to_configure);
topology
.configure_interface(&self.score.ports_to_configure)
.await
.map_err(|e| InterpretError::new(e.to_string()))?;
Ok(Outcome::success("switch configured".to_string()))
}
fn get_name(&self) -> InterpretName {
InterpretName::Custom("BrocadeSwitchInterpret")
}
fn get_version(&self) -> Version {
todo!()
}
fn get_status(&self) -> InterpretStatus {
todo!()
}
fn get_children(&self) -> Vec<Id> {
todo!()
}
}
/*
pub struct BrocadeSwitchConfig {
pub ips: Vec<harmony_types::net::IpAddress>,
pub username: String,
pub password: String,
pub options: BrocadeOptions,
}
*/
pub struct SwitchTopology {
client: Box<dyn SwitchClient>,
}
#[async_trait]
impl Topology for SwitchTopology {
fn name(&self) -> &str {
"SwitchTopology"
}
async fn ensure_ready(&self) -> Result<PreparationOutcome, PreparationError> {
Ok(PreparationOutcome::Noop)
}
}
impl SwitchTopology {
pub async fn new(config: BrocadeSwitchConfig) -> Self {
let client = BrocadeSwitchClient::init(config)
.await
.expect("Failed to connect to switch");
let client = Box::new(client);
Self { client }
}
}
#[async_trait]
impl Switch for SwitchTopology {
async fn setup_switch(&self) -> Result<(), SwitchError> {
todo!()
}
async fn get_port_for_mac_address(
&self,
_mac_address: &MacAddress,
) -> Result<Option<PortLocation>, SwitchError> {
todo!()
}
async fn configure_port_channel(&self, _config: &HostNetworkConfig) -> Result<(), SwitchError> {
todo!()
}
async fn clear_port_channel(&self, ids: &Vec<Id>) -> Result<(), SwitchError> {
self.client.clear_port_channel(ids).await
}
async fn configure_interface(&self, ports: &Vec<PortConfig>) -> Result<(), SwitchError> {
self.client.configure_interface(ports).await
}
}

View File

@@ -1,5 +0,0 @@
pub mod brocade;
pub use brocade::*;
pub mod brocade_snmp;
pub use brocade_snmp::*;

View File

@@ -1,4 +1,3 @@
use harmony_k8s::K8sClient;
use std::sync::Arc;
use async_trait::async_trait;
@@ -12,7 +11,7 @@ use crate::{
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
inventory::Inventory,
score::Score,
topology::{K8sclient, Topology},
topology::{K8sclient, Topology, k8s::K8sClient},
};
#[derive(Clone, Debug, Serialize)]

View File

@@ -82,40 +82,17 @@ impl<T: Topology> Interpret<T> for DiscoverHostForRoleInterpret {
self.score.role,
choice.summary()
);
let mut disk_choices: Vec<(String, String)> = vec![];
for s in choice.storage.iter() {
let size_gb: f64 = s.size_bytes as f64 / 1_000_000_000.0;
let (size, unit) = if size_gb >= 1000.0 {
(size_gb / 1000.0, "TB")
} else {
(size_gb, "GB")
};
let drive_type = if s.rotational { "rotational" } else { "SSD" };
let smart_str = s.smart_status.as_deref().unwrap_or("N/A");
let display = format!(
"{} : [{}] - {:.0} {} ({}) - {} - Smart: {}",
s.name, s.model, size, unit, drive_type, s.interface_type, smart_str
);
disk_choices.push((display, s.name.clone()));
}
let display_refs: Vec<&str> =
disk_choices.iter().map(|(d, _)| d.as_str()).collect();
let disk_names: Vec<String> =
choice.storage.iter().map(|s| s.name.clone()).collect();
let disk_choice = inquire::Select::new(
&format!("Select the disk to use on host {}:", choice.summary()),
display_refs,
disk_names,
)
.prompt();
match disk_choice {
Ok(selected_display) => {
let disk_name = disk_choices
.iter()
.find(|(d, _)| d.as_str() == selected_display)
.map(|(_, name)| name.clone())
.unwrap();
Ok(disk_name) => {
info!("Selected disk {} for node {}", disk_name, choice.summary());
host_repo
.save_role_mapping(&self.score.role, &choice, &disk_name)

View File

@@ -54,12 +54,6 @@ pub enum HarmonyDiscoveryStrategy {
SUBNET { cidr: cidr::Ipv4Cidr, port: u16 },
}
impl Default for HarmonyDiscoveryStrategy {
fn default() -> Self {
HarmonyDiscoveryStrategy::MDNS
}
}
#[async_trait]
impl<T: Topology> Interpret<T> for DiscoverInventoryAgentInterpret {
async fn execute(

View File

@@ -3,8 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use log::warn;
use crate::topology::{FailoverTopology, K8sclient};
use harmony_k8s::K8sClient;
use crate::topology::{FailoverTopology, K8sclient, k8s::K8sClient};
#[async_trait]
impl<T: K8sclient> K8sclient for FailoverTopology<T> {

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use k8s_openapi::ResourceScope;
use k8s_openapi::NamespaceResourceScope;
use kube::Resource;
use log::info;
use serde::{Serialize, de::DeserializeOwned};
@@ -29,7 +29,7 @@ impl<K: Resource + std::fmt::Debug> K8sResourceScore<K> {
}
impl<
K: Resource<Scope: ResourceScope>
K: Resource<Scope = NamespaceResourceScope>
+ std::fmt::Debug
+ Sync
+ DeserializeOwned
@@ -61,7 +61,7 @@ pub struct K8sResourceInterpret<K: Resource + std::fmt::Debug + Sync + Send> {
#[async_trait]
impl<
K: Resource<Scope: ResourceScope>
K: Resource<Scope = NamespaceResourceScope>
+ Clone
+ std::fmt::Debug
+ DeserializeOwned
@@ -109,7 +109,7 @@ where
topology
.k8s_client()
.await
.map_err(|e| InterpretError::new(format!("Failed to get k8s client : {e}")))?
.expect("Environment should provide enough information to instanciate a client")
.apply_many(&self.score.resource, self.score.namespace.as_deref())
.await?;

View File

@@ -15,13 +15,10 @@ pub mod load_balancer;
pub mod monitoring;
pub mod nats;
pub mod network;
pub mod node_health;
pub mod okd;
pub mod openbao;
pub mod opnsense;
pub mod postgresql;
pub mod prometheus;
pub mod storage;
pub mod tenant;
pub mod tftp;
pub mod zitadel;

View File

@@ -1,6 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: observability
labels:
openshift.io/cluster-monitoring: "true"

View File

@@ -1,43 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: cluster-grafana-sa
namespace: observability
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: grafana-prometheus-api-access
rules:
- apiGroups:
- monitoring.coreos.com
resources:
- prometheuses/api
verbs:
- get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: grafana-prometheus-api-access-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: grafana-prometheus-api-access
subjects:
- kind: ServiceAccount
name: cluster-grafana-sa
namespace: observability
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: grafana-cluster-monitoring-view
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-monitoring-view
subjects:
- kind: ServiceAccount
name: cluster-grafana-sa
namespace: observability

View File

@@ -1,43 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: Grafana
metadata:
name: cluster-grafana
namespace: observability
labels:
dashboards: "grafana"
spec:
serviceAccountName: cluster-grafana-sa
automountServiceAccountToken: true
config:
log:
mode: console
security:
admin_user: admin
admin_password: paul
users:
viewers_can_edit: "false"
auth:
disable_login_form: "false"
auth.anonymous:
enabled: "true"
org_role: Viewer
deployment:
spec:
replicas: 1
template:
spec:
containers:
- name: grafana
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 1
memory: 2Gi

View File

@@ -1,8 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: grafana-prometheus-token
namespace: observability
annotations:
kubernetes.io/service-account.name: cluster-grafana-sa
type: kubernetes.io/service-account-token

View File

@@ -1,27 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDatasource
metadata:
name: prometheus-cluster
namespace: observability
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
valuesFrom:
- targetPath: "secureJsonData.httpHeaderValue1"
valueFrom:
secretKeyRef:
name: grafana-prometheus-token
key: token
datasource:
name: Prometheus-Cluster
type: prometheus
access: proxy
url: https://prometheus-k8s.openshift-monitoring.svc:9091
isDefault: true
jsonData:
httpHeaderName1: "Authorization"
tlsSkipVerify: true
timeInterval: "30s"
secureJsonData:
httpHeaderValue1: "Bearer ${token}"

View File

@@ -1,14 +0,0 @@
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: grafana
namespace: observability
spec:
to:
kind: Service
name: cluster-grafana-service
port:
targetPort: 3000
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect

View File

@@ -1,97 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: cluster-overview
namespace: observability
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
json: |
{
"title": "Cluster Overview",
"schemaVersion": 36,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-1h",
"to": "now"
},
"panels": [
{
"type": "stat",
"title": "Ready Nodes",
"datasource": {
"type": "prometheus",
"uid": "Prometheus-Cluster"
},
"targets": [
{
"expr": "count(kube_node_status_condition{condition=\"Ready\",status=\"true\"})",
"refId": "A"
}
],
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 0 }
},
{
"type": "stat",
"title": "Running Pods",
"datasource": {
"type": "prometheus",
"uid": "Prometheus-Cluster"
},
"targets": [
{
"expr": "count(kube_pod_status_phase{phase=\"Running\"})",
"refId": "A"
}
],
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 0 }
},
{
"type": "timeseries",
"title": "Cluster CPU Usage (%)",
"datasource": {
"type": "prometheus",
"uid": "Prometheus-Cluster"
},
"targets": [
{
"expr": "100 * (1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])))",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }
},
{
"type": "timeseries",
"title": "Cluster Memory Usage (%)",
"datasource": {
"type": "prometheus",
"uid": "Prometheus-Cluster"
},
"targets": [
{
"expr": "100 * (1 - (sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)))",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }
}
]
}

View File

@@ -1,769 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: okd-cluster-overview
namespace: observability
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
json: |
{
"title": "Cluster Overview",
"uid": "okd-cluster-overview",
"schemaVersion": 36,
"version": 2,
"refresh": "30s",
"time": { "from": "now-1h", "to": "now" },
"tags": ["okd", "cluster", "overview"],
"panels": [
{
"id": 1,
"type": "stat",
"title": "Ready Nodes",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count(kube_node_status_condition{condition=\"Ready\",status=\"true\"} == 1)",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }
},
{
"id": 2,
"type": "stat",
"title": "Not Ready Nodes",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count(kube_node_status_condition{condition=\"Ready\",status=\"false\"} == 1) or vector(0)",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }
},
{
"id": 3,
"type": "stat",
"title": "Running Pods",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count(kube_pod_status_phase{phase=\"Running\"} == 1)",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }
},
{
"id": 4,
"type": "stat",
"title": "Pending Pods",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count(kube_pod_status_phase{phase=\"Pending\"} == 1) or vector(0)",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }
},
{
"id": 5,
"type": "stat",
"title": "Failed Pods",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count(kube_pod_status_phase{phase=\"Failed\"} == 1) or vector(0)",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }
},
{
"id": 6,
"type": "stat",
"title": "CrashLoopBackOff",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count(kube_pod_container_status_waiting_reason{reason=\"CrashLoopBackOff\"} == 1) or vector(0)",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }
},
{
"id": 7,
"type": "stat",
"title": "Critical Alerts",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count(ALERTS{alertstate=\"firing\",severity=\"critical\"}) or vector(0)",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 0 }
},
{
"id": 8,
"type": "stat",
"title": "Warning Alerts",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count(ALERTS{alertstate=\"firing\",severity=\"warning\"}) or vector(0)",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 10 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 4, "w": 3, "x": 21, "y": 0 }
},
{
"id": 9,
"type": "gauge",
"title": "CPU Usage",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])))",
"refId": "A",
"legendFormat": "CPU"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showThresholdLabels": false,
"showThresholdMarkers": true,
"orientation": "auto"
},
"gridPos": { "h": 6, "w": 5, "x": 0, "y": 4 }
},
{
"id": 10,
"type": "gauge",
"title": "Memory Usage",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - (sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)))",
"refId": "A",
"legendFormat": "Memory"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 75 },
{ "color": "red", "value": 90 }
]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showThresholdLabels": false,
"showThresholdMarkers": true,
"orientation": "auto"
},
"gridPos": { "h": 6, "w": 5, "x": 5, "y": 4 }
},
{
"id": 11,
"type": "gauge",
"title": "Root Disk Usage",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - (sum(node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}) / sum(node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"})))",
"refId": "A",
"legendFormat": "Disk"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showThresholdLabels": false,
"showThresholdMarkers": true,
"orientation": "auto"
},
"gridPos": { "h": 6, "w": 4, "x": 10, "y": 4 }
},
{
"id": 12,
"type": "stat",
"title": "etcd Has Leader",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "min(etcd_server_has_leader)",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"mappings": [
{
"type": "value",
"options": {
"0": { "text": "NO LEADER", "color": "red" },
"1": { "text": "LEADER OK", "color": "green" }
}
}
],
"unit": "short",
"noValue": "?"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 3, "w": 5, "x": 14, "y": 4 }
},
{
"id": 13,
"type": "stat",
"title": "API Servers Up",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(up{job=\"apiserver\"})",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "green", "value": 2 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 3, "w": 5, "x": 19, "y": 4 }
},
{
"id": 14,
"type": "stat",
"title": "etcd Members Up",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(up{job=\"etcd\"})",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 2 },
{ "color": "green", "value": 3 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 3, "w": 5, "x": 14, "y": 7 }
},
{
"id": 15,
"type": "stat",
"title": "Operators Degraded",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count(cluster_operator_conditions{condition=\"Degraded\",status=\"True\"} == 1) or vector(0)",
"refId": "A",
"legendFormat": ""
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]
},
"unit": "short",
"noValue": "0"
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto"
},
"gridPos": { "h": 3, "w": 5, "x": 19, "y": 7 }
},
{
"id": 16,
"type": "timeseries",
"title": "CPU Usage per Node (%)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])))",
"refId": "A",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": false,
"showPoints": "never"
}
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": {
"displayMode": "list",
"placement": "bottom",
"calcs": ["mean", "max"]
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 10 }
},
{
"id": 17,
"type": "timeseries",
"title": "Memory Usage per Node (%)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes))",
"refId": "A",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": false,
"showPoints": "never"
}
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": {
"displayMode": "list",
"placement": "bottom",
"calcs": ["mean", "max"]
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 10 }
},
{
"id": 18,
"type": "timeseries",
"title": "Network Traffic — Cluster Total",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(rate(node_network_receive_bytes_total{device!~\"lo|veth.*|tun.*|ovn.*|br-int|br-ex\"}[5m]))",
"refId": "A",
"legendFormat": "Receive"
},
{
"expr": "sum(rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|tun.*|ovn.*|br-int|br-ex\"}[5m]))",
"refId": "B",
"legendFormat": "Transmit"
}
],
"fieldConfig": {
"defaults": {
"unit": "Bps",
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": false,
"showPoints": "never"
}
},
"overrides": [
{
"matcher": { "id": "byName", "options": "Receive" },
"properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }]
},
{
"matcher": { "id": "byName", "options": "Transmit" },
"properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }]
}
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "none" },
"legend": {
"displayMode": "list",
"placement": "bottom",
"calcs": ["mean", "max"]
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 18 }
},
{
"id": 19,
"type": "timeseries",
"title": "Pod Phases Over Time",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count(kube_pod_status_phase{phase=\"Running\"} == 1)",
"refId": "A",
"legendFormat": "Running"
},
{
"expr": "count(kube_pod_status_phase{phase=\"Pending\"} == 1) or vector(0)",
"refId": "B",
"legendFormat": "Pending"
},
{
"expr": "count(kube_pod_status_phase{phase=\"Failed\"} == 1) or vector(0)",
"refId": "C",
"legendFormat": "Failed"
},
{
"expr": "count(kube_pod_status_phase{phase=\"Unknown\"} == 1) or vector(0)",
"refId": "D",
"legendFormat": "Unknown"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {
"lineWidth": 2,
"fillOpacity": 15,
"spanNulls": false,
"showPoints": "never"
}
},
"overrides": [
{
"matcher": { "id": "byName", "options": "Running" },
"properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }]
},
{
"matcher": { "id": "byName", "options": "Pending" },
"properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }]
},
{
"matcher": { "id": "byName", "options": "Failed" },
"properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }]
},
{
"matcher": { "id": "byName", "options": "Unknown" },
"properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }]
}
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "none" },
"legend": {
"displayMode": "list",
"placement": "bottom",
"calcs": ["lastNotNull"]
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 18 }
}
]
}

View File

@@ -1,637 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: okd-node-health
namespace: observability
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
json: |
{
"title": "Node Health",
"uid": "okd-node-health",
"schemaVersion": 36,
"version": 2,
"refresh": "30s",
"time": { "from": "now-1h", "to": "now" },
"tags": ["okd", "node", "health"],
"templating": {
"list": [
{
"name": "node",
"type": "query",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"query": { "query": "label_values(kube_node_info, node)", "refId": "A" },
"refresh": 2,
"includeAll": true,
"multi": true,
"allValue": ".*",
"label": "Node",
"sort": 1,
"current": {},
"options": []
}
]
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Total Nodes",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_node_info{node=~\"$node\"})", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }
},
{
"id": 2,
"type": "stat",
"title": "Ready Nodes",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_node_status_condition{condition=\"Ready\",status=\"true\",node=~\"$node\"} == 1)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }
},
{
"id": 3,
"type": "stat",
"title": "Not Ready Nodes",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_node_status_condition{condition=\"Ready\",status=\"false\",node=~\"$node\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }
},
{
"id": 4,
"type": "stat",
"title": "Memory Pressure",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_node_status_condition{condition=\"MemoryPressure\",status=\"true\",node=~\"$node\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }
},
{
"id": 5,
"type": "stat",
"title": "Disk Pressure",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\",node=~\"$node\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }
},
{
"id": 6,
"type": "stat",
"title": "PID Pressure",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_node_status_condition{condition=\"PIDPressure\",status=\"true\",node=~\"$node\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }
},
{
"id": 7,
"type": "stat",
"title": "Unschedulable",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_node_spec_unschedulable{node=~\"$node\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 0 }
},
{
"id": 8,
"type": "stat",
"title": "Kubelet Up",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(up{job=\"kubelet\",metrics_path=\"/metrics\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 21, "y": 0 }
},
{
"id": 9,
"type": "table",
"title": "Node Conditions",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(node) (kube_node_status_condition{condition=\"Ready\",status=\"true\",node=~\"$node\"})",
"refId": "A",
"legendFormat": "{{node}}",
"instant": true
},
{
"expr": "sum by(node) (kube_node_status_condition{condition=\"MemoryPressure\",status=\"true\",node=~\"$node\"})",
"refId": "B",
"legendFormat": "{{node}}",
"instant": true
},
{
"expr": "sum by(node) (kube_node_status_condition{condition=\"DiskPressure\",status=\"true\",node=~\"$node\"})",
"refId": "C",
"legendFormat": "{{node}}",
"instant": true
},
{
"expr": "sum by(node) (kube_node_status_condition{condition=\"PIDPressure\",status=\"true\",node=~\"$node\"})",
"refId": "D",
"legendFormat": "{{node}}",
"instant": true
},
{
"expr": "sum by(node) (kube_node_spec_unschedulable{node=~\"$node\"})",
"refId": "E",
"legendFormat": "{{node}}",
"instant": true
}
],
"transformations": [
{
"id": "labelsToFields",
"options": { "mode": "columns" }
},
{
"id": "joinByField",
"options": { "byField": "node", "mode": "outer" }
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Time 1": true,
"Time 2": true,
"Time 3": true,
"Time 4": true,
"Time 5": true
},
"renameByName": {
"node": "Node",
"Value #A": "Ready",
"Value #B": "Mem Pressure",
"Value #C": "Disk Pressure",
"Value #D": "PID Pressure",
"Value #E": "Unschedulable"
},
"indexByName": {
"node": 0,
"Value #A": 1,
"Value #B": 2,
"Value #C": 3,
"Value #D": 4,
"Value #E": 5
}
}
}
],
"fieldConfig": {
"defaults": {
"custom": { "displayMode": "color-background", "align": "center" }
},
"overrides": [
{
"matcher": { "id": "byName", "options": "Node" },
"properties": [
{ "id": "custom.displayMode", "value": "auto" },
{ "id": "custom.align", "value": "left" },
{ "id": "custom.width", "value": 200 }
]
},
{
"matcher": { "id": "byName", "options": "Ready" },
"properties": [
{
"id": "thresholds",
"value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
},
{ "id": "custom.displayMode", "value": "color-background" },
{
"id": "mappings",
"value": [
{
"type": "value",
"options": {
"0": { "text": "✗ Not Ready", "color": "red", "index": 0 },
"1": { "text": "✓ Ready", "color": "green", "index": 1 }
}
}
]
}
]
},
{
"matcher": { "id": "byRegexp", "options": ".*Pressure" },
"properties": [
{
"id": "thresholds",
"value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
},
{ "id": "custom.displayMode", "value": "color-background" },
{
"id": "mappings",
"value": [
{
"type": "value",
"options": {
"0": { "text": "✓ OK", "color": "green", "index": 0 },
"1": { "text": "⚠ Active", "color": "red", "index": 1 }
}
}
]
}
]
},
{
"matcher": { "id": "byName", "options": "Unschedulable" },
"properties": [
{
"id": "thresholds",
"value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }] }
},
{ "id": "custom.displayMode", "value": "color-background" },
{
"id": "mappings",
"value": [
{
"type": "value",
"options": {
"0": { "text": "✓ Schedulable", "color": "green", "index": 0 },
"1": { "text": "⚠ Cordoned", "color": "yellow", "index": 1 }
}
}
]
}
]
}
]
},
"options": { "sortBy": [{ "displayName": "Node", "desc": false }] },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 4 }
},
{
"id": 10,
"type": "timeseries",
"title": "CPU Usage per Node (%)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])))",
"refId": "A",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent", "min": 0, "max": 100,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 70 }, { "color": "red", "value": 85 }] }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 16, "x": 0, "y": 12 }
},
{
"id": 11,
"type": "bargauge",
"title": "CPU Usage \u2014 Current",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])))",
"refId": "A",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent", "min": 0, "max": 100,
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 70 }, { "color": "red", "value": 85 }] }
}
},
"options": {
"orientation": "horizontal",
"displayMode": "gradient",
"showUnfilled": true,
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 }
},
{
"id": 12,
"type": "timeseries",
"title": "Memory Usage per Node (%)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes))",
"refId": "A",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent", "min": 0, "max": 100,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 75 }, { "color": "red", "value": 90 }] }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 16, "x": 0, "y": 20 }
},
{
"id": 13,
"type": "bargauge",
"title": "Memory Usage \u2014 Current",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes))",
"refId": "A",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent", "min": 0, "max": 100,
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 75 }, { "color": "red", "value": 90 }] }
}
},
"options": {
"orientation": "horizontal",
"displayMode": "gradient",
"showUnfilled": true,
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 20 }
},
{
"id": 14,
"type": "timeseries",
"title": "Root Disk Usage per Node (%)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - (node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}))",
"refId": "A",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent", "min": 0, "max": 100,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 70 }, { "color": "red", "value": 85 }] }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 16, "x": 0, "y": 28 }
},
{
"id": 15,
"type": "bargauge",
"title": "Root Disk Usage \u2014 Current",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (1 - (node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}))",
"refId": "A",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent", "min": 0, "max": 100,
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 70 }, { "color": "red", "value": 85 }] }
}
},
"options": {
"orientation": "horizontal",
"displayMode": "gradient",
"showUnfilled": true,
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 28 }
},
{
"id": 16,
"type": "timeseries",
"title": "Network Traffic per Node",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(instance) (rate(node_network_receive_bytes_total{device!~\"lo|veth.*|tun.*|ovn.*|br.*\"}[5m]))",
"refId": "A",
"legendFormat": "rx {{instance}}"
},
{
"expr": "sum by(instance) (rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|tun.*|ovn.*|br.*\"}[5m]))",
"refId": "B",
"legendFormat": "tx {{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "Bps",
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 36 }
},
{
"id": 17,
"type": "bargauge",
"title": "Pods per Node",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "count by(node) (kube_pod_info{node=~\"$node\"})",
"refId": "A",
"legendFormat": "{{node}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 100 },
{ "color": "red", "value": 200 }
]
}
}
},
"options": {
"orientation": "horizontal",
"displayMode": "gradient",
"showUnfilled": true,
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 36 }
},
{
"id": 18,
"type": "timeseries",
"title": "System Load Average (1m) per Node",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "node_load1",
"refId": "A",
"legendFormat": "1m \u2014 {{instance}}"
},
{
"expr": "node_load5",
"refId": "B",
"legendFormat": "5m \u2014 {{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 44 }
},
{
"id": 19,
"type": "bargauge",
"title": "Node Uptime",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "time() - node_boot_time_seconds",
"refId": "A",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"min": 0,
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 300 },
{ "color": "green", "value": 3600 }
]
}
}
},
"options": {
"orientation": "horizontal",
"displayMode": "gradient",
"showUnfilled": false,
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 44 }
}
]
}

View File

@@ -1,783 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: okd-workload-health
namespace: observability
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
json: |
{
"title": "Workload Health",
"uid": "okd-workload-health",
"schemaVersion": 36,
"version": 3,
"refresh": "30s",
"time": { "from": "now-1h", "to": "now" },
"tags": ["okd", "workload", "health"],
"templating": {
"list": [
{
"name": "namespace",
"type": "query",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"query": { "query": "label_values(kube_pod_info, namespace)", "refId": "A" },
"refresh": 2,
"includeAll": true,
"multi": true,
"allValue": ".*",
"label": "Namespace",
"sort": 1,
"current": {},
"options": []
}
]
},
"panels": [
{
"id": 1, "type": "stat", "title": "Total Pods",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_pod_info{namespace=~\"$namespace\"})", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }
},
{
"id": 2, "type": "stat", "title": "Running Pods",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_pod_status_phase{phase=\"Running\",namespace=~\"$namespace\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }
},
{
"id": 3, "type": "stat", "title": "Pending Pods",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_pod_status_phase{phase=\"Pending\",namespace=~\"$namespace\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }
},
{
"id": 4, "type": "stat", "title": "Failed Pods",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_pod_status_phase{phase=\"Failed\",namespace=~\"$namespace\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }
},
{
"id": 5, "type": "stat", "title": "CrashLoopBackOff",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_pod_container_status_waiting_reason{reason=\"CrashLoopBackOff\",namespace=~\"$namespace\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }
},
{
"id": 6, "type": "stat", "title": "OOMKilled",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_pod_container_status_last_terminated_reason{reason=\"OOMKilled\",namespace=~\"$namespace\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }
},
{
"id": 7, "type": "stat", "title": "Deployments Available",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_deployment_status_condition{condition=\"Available\",status=\"true\",namespace=~\"$namespace\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 0 }
},
{
"id": 8, "type": "stat", "title": "Deployments Degraded",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_deployment_status_replicas_unavailable{namespace=~\"$namespace\"} > 0) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 21, "y": 0 }
},
{
"id": 9, "type": "row", "title": "Deployments", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 4 }
},
{
"id": 10,
"type": "table",
"title": "Deployment Status",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(namespace,deployment)(kube_deployment_spec_replicas{namespace=~\"$namespace\"})",
"refId": "A",
"instant": true,
"format": "table",
"legendFormat": ""
},
{
"expr": "sum by(namespace,deployment)(kube_deployment_status_replicas_ready{namespace=~\"$namespace\"})",
"refId": "B",
"instant": true,
"format": "table",
"legendFormat": ""
},
{
"expr": "sum by(namespace,deployment)(kube_deployment_status_replicas_available{namespace=~\"$namespace\"})",
"refId": "C",
"instant": true,
"format": "table",
"legendFormat": ""
},
{
"expr": "sum by(namespace,deployment)(kube_deployment_status_replicas_unavailable{namespace=~\"$namespace\"})",
"refId": "D",
"instant": true,
"format": "table",
"legendFormat": ""
},
{
"expr": "sum by(namespace,deployment)(kube_deployment_status_replicas_updated{namespace=~\"$namespace\"})",
"refId": "E",
"instant": true,
"format": "table",
"legendFormat": ""
}
],
"transformations": [
{
"id": "filterFieldsByName",
"options": {
"include": {
"names": ["namespace", "deployment", "Value"]
}
}
},
{
"id": "joinByField",
"options": {
"byField": "deployment",
"mode": "outer"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"namespace 1": true,
"namespace 2": true,
"namespace 3": true,
"namespace 4": true
},
"renameByName": {
"namespace": "Namespace",
"deployment": "Deployment",
"Value": "Desired",
"Value 1": "Ready",
"Value 2": "Available",
"Value 3": "Unavailable",
"Value 4": "Up-to-date"
},
"indexByName": {
"namespace": 0,
"deployment": 1,
"Value": 2,
"Value 1": 3,
"Value 2": 4,
"Value 3": 5,
"Value 4": 6
}
}
},
{
"id": "sortBy",
"options": {
"fields": [{ "displayName": "Namespace", "desc": false }]
}
}
],
"fieldConfig": {
"defaults": { "custom": { "align": "center", "displayMode": "auto" } },
"overrides": [
{
"matcher": { "id": "byName", "options": "Namespace" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 200 }]
},
{
"matcher": { "id": "byName", "options": "Deployment" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 220 }]
},
{
"matcher": { "id": "byName", "options": "Unavailable" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{
"id": "thresholds",
"value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
}
]
},
{
"matcher": { "id": "byName", "options": "Ready" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{
"id": "thresholds",
"value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
}
]
}
]
},
"options": { "sortBy": [{ "displayName": "Namespace", "desc": false }] },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 5 }
},
{
"id": 11, "type": "row", "title": "StatefulSets & DaemonSets", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 13 }
},
{
"id": 12,
"type": "table",
"title": "StatefulSet Status",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(namespace,statefulset)(kube_statefulset_replicas{namespace=~\"$namespace\"})",
"refId": "A",
"instant": true,
"format": "table",
"legendFormat": ""
},
{
"expr": "sum by(namespace,statefulset)(kube_statefulset_status_replicas_ready{namespace=~\"$namespace\"})",
"refId": "B",
"instant": true,
"format": "table",
"legendFormat": ""
},
{
"expr": "sum by(namespace,statefulset)(kube_statefulset_status_replicas_current{namespace=~\"$namespace\"})",
"refId": "C",
"instant": true,
"format": "table",
"legendFormat": ""
},
{
"expr": "sum by(namespace,statefulset)(kube_statefulset_status_replicas_updated{namespace=~\"$namespace\"})",
"refId": "D",
"instant": true,
"format": "table",
"legendFormat": ""
}
],
"transformations": [
{
"id": "filterFieldsByName",
"options": {
"include": {
"names": ["namespace", "statefulset", "Value"]
}
}
},
{
"id": "joinByField",
"options": {
"byField": "statefulset",
"mode": "outer"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"namespace 1": true,
"namespace 2": true,
"namespace 3": true
},
"renameByName": {
"namespace": "Namespace",
"statefulset": "StatefulSet",
"Value": "Desired",
"Value 1": "Ready",
"Value 2": "Current",
"Value 3": "Up-to-date"
},
"indexByName": {
"namespace": 0,
"statefulset": 1,
"Value": 2,
"Value 1": 3,
"Value 2": 4,
"Value 3": 5
}
}
},
{
"id": "sortBy",
"options": { "fields": [{ "displayName": "Namespace", "desc": false }] }
}
],
"fieldConfig": {
"defaults": { "custom": { "align": "center", "displayMode": "auto" } },
"overrides": [
{
"matcher": { "id": "byName", "options": "Namespace" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 180 }]
},
{
"matcher": { "id": "byName", "options": "StatefulSet" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 200 }]
},
{
"matcher": { "id": "byName", "options": "Ready" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } }
]
}
]
},
"options": {},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }
},
{
"id": 13,
"type": "table",
"title": "DaemonSet Status",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(namespace,daemonset)(kube_daemonset_status_desired_number_scheduled{namespace=~\"$namespace\"})",
"refId": "A",
"instant": true,
"format": "table",
"legendFormat": ""
},
{
"expr": "sum by(namespace,daemonset)(kube_daemonset_status_number_ready{namespace=~\"$namespace\"})",
"refId": "B",
"instant": true,
"format": "table",
"legendFormat": ""
},
{
"expr": "sum by(namespace,daemonset)(kube_daemonset_status_number_unavailable{namespace=~\"$namespace\"})",
"refId": "C",
"instant": true,
"format": "table",
"legendFormat": ""
},
{
"expr": "sum by(namespace,daemonset)(kube_daemonset_status_number_misscheduled{namespace=~\"$namespace\"})",
"refId": "D",
"instant": true,
"format": "table",
"legendFormat": ""
}
],
"transformations": [
{
"id": "filterFieldsByName",
"options": {
"include": {
"names": ["namespace", "daemonset", "Value"]
}
}
},
{
"id": "joinByField",
"options": {
"byField": "daemonset",
"mode": "outer"
}
},
{
"id": "organize",
"options": {
"excludeByName": {
"namespace 1": true,
"namespace 2": true,
"namespace 3": true
},
"renameByName": {
"namespace": "Namespace",
"daemonset": "DaemonSet",
"Value": "Desired",
"Value 1": "Ready",
"Value 2": "Unavailable",
"Value 3": "Misscheduled"
},
"indexByName": {
"namespace": 0,
"daemonset": 1,
"Value": 2,
"Value 1": 3,
"Value 2": 4,
"Value 3": 5
}
}
},
{
"id": "sortBy",
"options": { "fields": [{ "displayName": "Namespace", "desc": false }] }
}
],
"fieldConfig": {
"defaults": { "custom": { "align": "center", "displayMode": "auto" } },
"overrides": [
{
"matcher": { "id": "byName", "options": "Namespace" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 180 }]
},
{
"matcher": { "id": "byName", "options": "DaemonSet" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 200 }]
},
{
"matcher": { "id": "byName", "options": "Ready" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } }
]
},
{
"matcher": { "id": "byName", "options": "Unavailable" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } }
]
},
{
"matcher": { "id": "byName", "options": "Misscheduled" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] } }
]
}
]
},
"options": {},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }
},
{
"id": 14, "type": "row", "title": "Pods", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 }
},
{
"id": 15,
"type": "timeseries",
"title": "Pod Phase over Time",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(phase)(kube_pod_status_phase{namespace=~\"$namespace\"})",
"refId": "A", "legendFormat": "{{phase}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "Running" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Pending" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Failed" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Succeeded" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Unknown" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull"] }
},
"gridPos": { "h": 8, "w": 16, "x": 0, "y": 23 }
},
{
"id": 16,
"type": "piechart",
"title": "Pod Phase — Now",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(phase)(kube_pod_status_phase{namespace=~\"$namespace\"})",
"refId": "A", "instant": true, "legendFormat": "{{phase}}"
}
],
"fieldConfig": {
"defaults": { "unit": "short", "color": { "mode": "palette-classic" } },
"overrides": [
{ "matcher": { "id": "byName", "options": "Running" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Pending" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Failed" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Succeeded" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Unknown" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }
]
},
"options": {
"pieType": "donut",
"tooltip": { "mode": "single" },
"legend": { "displayMode": "table", "placement": "right", "values": ["value", "percent"] }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 23 }
},
{
"id": 17,
"type": "timeseries",
"title": "Container Restarts over Time (total counter, top 10)",
"description": "Absolute restart counter — each vertical step = a restart event. Flat line = healthy.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "topk(10,\n sum by(namespace, pod) (\n kube_pod_container_status_restarts_total{namespace=~\"$namespace\"}\n ) > 0\n)",
"refId": "A",
"legendFormat": "{{namespace}} / {{pod}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "auto", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 31 }
},
{
"id": 18,
"type": "table",
"title": "Container Total Restarts (non-zero)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(namespace, pod, container) (kube_pod_container_status_restarts_total{namespace=~\"$namespace\"}) > 0",
"refId": "A",
"instant": true,
"format": "table",
"legendFormat": ""
}
],
"transformations": [
{
"id": "filterFieldsByName",
"options": {
"include": { "names": ["namespace", "pod", "container", "Value"] }
}
},
{
"id": "organize",
"options": {
"excludeByName": {},
"renameByName": {
"namespace": "Namespace",
"pod": "Pod",
"container": "Container",
"Value": "Total Restarts"
},
"indexByName": { "namespace": 0, "pod": 1, "container": 2, "Value": 3 }
}
},
{
"id": "sortBy",
"options": { "fields": [{ "displayName": "Total Restarts", "desc": true }] }
}
],
"fieldConfig": {
"defaults": { "custom": { "align": "center", "displayMode": "auto" } },
"overrides": [
{ "matcher": { "id": "byName", "options": "Namespace" }, "properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 160 }] },
{ "matcher": { "id": "byName", "options": "Pod" }, "properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 260 }] },
{ "matcher": { "id": "byName", "options": "Container" }, "properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 160 }] },
{
"matcher": { "id": "byName", "options": "Total Restarts" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "yellow", "value": null }, { "color": "orange", "value": 5 }, { "color": "red", "value": 20 }] } }
]
}
]
},
"options": {},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 31 }
},
{
"id": 19, "type": "row", "title": "Resource Usage", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 39 }
},
{
"id": 20,
"type": "timeseries",
"title": "CPU Usage by Namespace",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(namespace)(rate(container_cpu_usage_seconds_total{namespace=~\"$namespace\",container!=\"\",container!=\"POD\"}[5m]))",
"refId": "A", "legendFormat": "{{namespace}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "cores", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 40 }
},
{
"id": 21,
"type": "timeseries",
"title": "Memory Usage by Namespace",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(namespace)(container_memory_working_set_bytes{namespace=~\"$namespace\",container!=\"\",container!=\"POD\"})",
"refId": "A", "legendFormat": "{{namespace}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 40 }
},
{
"id": 22,
"type": "bargauge",
"title": "CPU — Actual vs Requested (%)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(namespace)(rate(container_cpu_usage_seconds_total{namespace=~\"$namespace\",container!=\"\",container!=\"POD\"}[5m]))\n/\nsum by(namespace)(kube_pod_container_resource_requests{resource=\"cpu\",namespace=~\"$namespace\",container!=\"\"})\n* 100",
"refId": "A", "legendFormat": "{{namespace}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent", "min": 0, "max": 150,
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 80 }, { "color": "red", "value": 100 }] }
}
},
"options": {
"orientation": "horizontal", "displayMode": "gradient", "showUnfilled": true,
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 48 }
},
{
"id": 23,
"type": "bargauge",
"title": "Memory — Actual vs Requested (%)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(namespace)(container_memory_working_set_bytes{namespace=~\"$namespace\",container!=\"\",container!=\"POD\"})\n/\nsum by(namespace)(kube_pod_container_resource_requests{resource=\"memory\",namespace=~\"$namespace\",container!=\"\"})\n* 100",
"refId": "A", "legendFormat": "{{namespace}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent", "min": 0, "max": 150,
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 80 }, { "color": "red", "value": 100 }] }
}
},
"options": {
"orientation": "horizontal", "displayMode": "gradient", "showUnfilled": true,
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 48 }
}
]
}

View File

@@ -1,955 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: okd-networking
namespace: observability
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
json: |
{
"title": "Networking",
"uid": "okd-networking",
"schemaVersion": 36,
"version": 1,
"refresh": "30s",
"time": { "from": "now-1h", "to": "now" },
"tags": ["okd", "networking"],
"templating": {
"list": [
{
"name": "namespace",
"type": "query",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"query": { "query": "label_values(kube_pod_info, namespace)", "refId": "A" },
"refresh": 2,
"includeAll": true,
"multi": true,
"allValue": ".*",
"label": "Namespace",
"sort": 1,
"current": {},
"options": []
}
]
},
"panels": [
{
"id": 1, "type": "stat", "title": "Network RX Rate",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(rate(container_network_receive_bytes_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] },
"unit": "Bps", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }
},
{
"id": 2, "type": "stat", "title": "Network TX Rate",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(rate(container_network_transmit_bytes_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] },
"unit": "Bps", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }
},
{
"id": 3, "type": "stat", "title": "RX Errors/s",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(rate(container_network_receive_errors_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"unit": "pps", "noValue": "0", "decimals": 2
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }
},
{
"id": 4, "type": "stat", "title": "TX Errors/s",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(rate(container_network_transmit_errors_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"unit": "pps", "noValue": "0", "decimals": 2
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }
},
{
"id": 5, "type": "stat", "title": "RX Drops/s",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(rate(container_network_receive_packets_dropped_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] },
"unit": "pps", "noValue": "0", "decimals": 2
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }
},
{
"id": 6, "type": "stat", "title": "TX Drops/s",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(rate(container_network_transmit_packets_dropped_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] },
"unit": "pps", "noValue": "0", "decimals": 2
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }
},
{
"id": 7, "type": "stat", "title": "DNS Queries/s",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(rate(coredns_dns_requests_total[5m]))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] },
"unit": "reqps", "noValue": "0", "decimals": 1
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 0 }
},
{
"id": 8, "type": "stat", "title": "DNS Error %",
"description": "Percentage of DNS responses with non-NOERROR rcode over the last 5 minutes.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(rate(coredns_dns_responses_total{rcode!=\"NOERROR\"}[5m])) / sum(rate(coredns_dns_responses_total[5m])) * 100",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]},
"unit": "percent", "noValue": "0", "decimals": 2
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 21, "y": 0 }
},
{
"id": 9, "type": "row", "title": "Network I/O", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 4 }
},
{
"id": 10, "type": "timeseries", "title": "Receive Rate by Namespace",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(namespace)(rate(container_network_receive_bytes_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": "{{namespace}}"
}],
"fieldConfig": {
"defaults": {
"unit": "Bps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 }
},
{
"id": 11, "type": "timeseries", "title": "Transmit Rate by Namespace",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(namespace)(rate(container_network_transmit_bytes_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": "{{namespace}}"
}],
"fieldConfig": {
"defaults": {
"unit": "Bps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 5 }
},
{
"id": 12, "type": "row", "title": "Top Pod Consumers", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 13 }
},
{
"id": 13, "type": "timeseries", "title": "Top 10 Pods — RX Rate",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "topk(10, sum by(namespace,pod)(rate(container_network_receive_bytes_total{namespace=~\"$namespace\",pod!=\"\"}[5m])))",
"refId": "A", "legendFormat": "{{namespace}} / {{pod}}"
}],
"fieldConfig": {
"defaults": {
"unit": "Bps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "auto", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }
},
{
"id": 14, "type": "timeseries", "title": "Top 10 Pods — TX Rate",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "topk(10, sum by(namespace,pod)(rate(container_network_transmit_bytes_total{namespace=~\"$namespace\",pod!=\"\"}[5m])))",
"refId": "A", "legendFormat": "{{namespace}} / {{pod}}"
}],
"fieldConfig": {
"defaults": {
"unit": "Bps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "auto", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }
},
{
"id": 15,
"type": "table",
"title": "Pod Network I/O Summary",
"description": "Current RX/TX rates, errors and drops per pod. Sorted by RX rate descending.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(namespace,pod)(rate(container_network_receive_bytes_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "instant": true, "format": "table", "legendFormat": ""
},
{
"expr": "sum by(namespace,pod)(rate(container_network_transmit_bytes_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "B", "instant": true, "format": "table", "legendFormat": ""
},
{
"expr": "sum by(namespace,pod)(rate(container_network_receive_errors_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "C", "instant": true, "format": "table", "legendFormat": ""
},
{
"expr": "sum by(namespace,pod)(rate(container_network_transmit_errors_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "D", "instant": true, "format": "table", "legendFormat": ""
},
{
"expr": "sum by(namespace,pod)(rate(container_network_receive_packets_dropped_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "E", "instant": true, "format": "table", "legendFormat": ""
},
{
"expr": "sum by(namespace,pod)(rate(container_network_transmit_packets_dropped_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "F", "instant": true, "format": "table", "legendFormat": ""
}
],
"transformations": [
{
"id": "filterFieldsByName",
"options": { "include": { "names": ["namespace", "pod", "Value"] } }
},
{
"id": "joinByField",
"options": { "byField": "pod", "mode": "outer" }
},
{
"id": "organize",
"options": {
"excludeByName": {
"namespace 1": true,
"namespace 2": true,
"namespace 3": true,
"namespace 4": true,
"namespace 5": true
},
"renameByName": {
"namespace": "Namespace",
"pod": "Pod",
"Value": "RX Rate",
"Value 1": "TX Rate",
"Value 2": "RX Errors/s",
"Value 3": "TX Errors/s",
"Value 4": "RX Drops/s",
"Value 5": "TX Drops/s"
},
"indexByName": {
"namespace": 0,
"pod": 1,
"Value": 2,
"Value 1": 3,
"Value 2": 4,
"Value 3": 5,
"Value 4": 6,
"Value 5": 7
}
}
},
{
"id": "sortBy",
"options": { "fields": [{ "displayName": "RX Rate", "desc": true }] }
}
],
"fieldConfig": {
"defaults": { "custom": { "align": "center", "displayMode": "auto" } },
"overrides": [
{
"matcher": { "id": "byName", "options": "Namespace" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 160 }]
},
{
"matcher": { "id": "byName", "options": "Pod" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 260 }]
},
{
"matcher": { "id": "byRegexp", "options": "^RX Rate$|^TX Rate$" },
"properties": [
{ "id": "unit", "value": "Bps" },
{ "id": "custom.displayMode", "value": "color-background-solid" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 10000000 },
{ "color": "orange", "value": 100000000 },
{ "color": "red", "value": 500000000 }
]}}
]
},
{
"matcher": { "id": "byRegexp", "options": "^RX Errors/s$|^TX Errors/s$" },
"properties": [
{ "id": "unit", "value": "pps" },
{ "id": "decimals", "value": 3 },
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 0.001 }
]}}
]
},
{
"matcher": { "id": "byRegexp", "options": "^RX Drops/s$|^TX Drops/s$" },
"properties": [
{ "id": "unit", "value": "pps" },
{ "id": "decimals", "value": 3 },
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 0.001 }
]}}
]
}
]
},
"options": {},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 22 }
},
{
"id": 16, "type": "row", "title": "Errors & Packet Loss", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 30 }
},
{
"id": 17, "type": "timeseries", "title": "RX Errors by Namespace",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(namespace)(rate(container_network_receive_errors_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": "{{namespace}}"
}],
"fieldConfig": {
"defaults": {
"unit": "pps", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 12, "x": 0, "y": 31 }
},
{
"id": 18, "type": "timeseries", "title": "TX Errors by Namespace",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(namespace)(rate(container_network_transmit_errors_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": "{{namespace}}"
}],
"fieldConfig": {
"defaults": {
"unit": "pps", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 12, "x": 12, "y": 31 }
},
{
"id": 19, "type": "timeseries", "title": "RX Packet Drops by Namespace",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(namespace)(rate(container_network_receive_packets_dropped_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": "{{namespace}}"
}],
"fieldConfig": {
"defaults": {
"unit": "pps", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 12, "x": 0, "y": 38 }
},
{
"id": 20, "type": "timeseries", "title": "TX Packet Drops by Namespace",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(namespace)(rate(container_network_transmit_packets_dropped_total{namespace=~\"$namespace\",pod!=\"\"}[5m]))",
"refId": "A", "legendFormat": "{{namespace}}"
}],
"fieldConfig": {
"defaults": {
"unit": "pps", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 12, "x": 12, "y": 38 }
},
{
"id": 21, "type": "row", "title": "DNS (CoreDNS)", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 45 }
},
{
"id": 22, "type": "timeseries", "title": "DNS Request Rate by Query Type",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(type)(rate(coredns_dns_requests_total[5m]))",
"refId": "A", "legendFormat": "{{type}}"
}],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 46 }
},
{
"id": 23, "type": "timeseries", "title": "DNS Response Rate by Rcode",
"description": "NOERROR = healthy. NXDOMAIN = name not found. SERVFAIL = upstream error.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(rcode)(rate(coredns_dns_responses_total[5m]))",
"refId": "A", "legendFormat": "{{rcode}}"
}],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "NOERROR" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "NXDOMAIN" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "SERVFAIL" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "REFUSED" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 46 }
},
{
"id": 24, "type": "timeseries", "title": "DNS Request Latency (p50 / p95 / p99)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "histogram_quantile(0.50, sum(rate(coredns_dns_request_duration_seconds_bucket[5m])) by (le))",
"refId": "A", "legendFormat": "p50"
},
{
"expr": "histogram_quantile(0.95, sum(rate(coredns_dns_request_duration_seconds_bucket[5m])) by (le))",
"refId": "B", "legendFormat": "p95"
},
{
"expr": "histogram_quantile(0.99, sum(rate(coredns_dns_request_duration_seconds_bucket[5m])) by (le))",
"refId": "C", "legendFormat": "p99"
}
],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "p50" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p95" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p99" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 46 }
},
{
"id": 25, "type": "timeseries", "title": "DNS Cache Hit Ratio (%)",
"description": "High hit ratio = CoreDNS is serving responses from cache, reducing upstream load.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(rate(coredns_cache_hits_total[5m])) / (sum(rate(coredns_cache_hits_total[5m])) + sum(rate(coredns_cache_misses_total[5m]))) * 100",
"refId": "A", "legendFormat": "Cache Hit %"
}],
"fieldConfig": {
"defaults": {
"unit": "percent", "min": 0, "max": 100,
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 50 },
{ "color": "green", "value": 80 }
]},
"custom": { "lineWidth": 2, "fillOpacity": 20, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "single" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "lastNotNull"] }
},
"gridPos": { "h": 7, "w": 12, "x": 0, "y": 54 }
},
{
"id": 26, "type": "timeseries", "title": "DNS Forward Request Rate",
"description": "Queries CoreDNS is forwarding upstream. Spike here with cache miss spike = upstream DNS pressure.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(rate(coredns_forward_requests_total[5m]))",
"refId": "A", "legendFormat": "Forward Requests/s"
},
{
"expr": "sum(rate(coredns_forward_responses_duration_seconds_count[5m]))",
"refId": "B", "legendFormat": "Forward Responses/s"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 12, "x": 12, "y": 54 }
},
{
"id": 27, "type": "row", "title": "Services & Endpoints", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 61 }
},
{
"id": 28, "type": "stat", "title": "Total Services",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "count(kube_service_info{namespace=~\"$namespace\"})",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 62 }
},
{
"id": 29, "type": "stat", "title": "Endpoint Addresses Available",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(kube_endpoint_address_available{namespace=~\"$namespace\"})",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 62 }
},
{
"id": 30, "type": "stat", "title": "Endpoint Addresses Not Ready",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum(kube_endpoint_address_not_ready{namespace=~\"$namespace\"}) or vector(0)",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 62 }
},
{
"id": 31,
"type": "table",
"title": "Endpoint Availability",
"description": "Per-endpoint available vs not-ready address counts. Red Not Ready = pods backing this service are unhealthy.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(namespace,endpoint)(kube_endpoint_address_available{namespace=~\"$namespace\"})",
"refId": "A", "instant": true, "format": "table", "legendFormat": ""
},
{
"expr": "sum by(namespace,endpoint)(kube_endpoint_address_not_ready{namespace=~\"$namespace\"})",
"refId": "B", "instant": true, "format": "table", "legendFormat": ""
}
],
"transformations": [
{
"id": "filterFieldsByName",
"options": { "include": { "names": ["namespace", "endpoint", "Value"] } }
},
{
"id": "joinByField",
"options": { "byField": "endpoint", "mode": "outer" }
},
{
"id": "organize",
"options": {
"excludeByName": { "namespace 1": true },
"renameByName": {
"namespace": "Namespace",
"endpoint": "Endpoint",
"Value": "Available",
"Value 1": "Not Ready"
},
"indexByName": {
"namespace": 0,
"endpoint": 1,
"Value": 2,
"Value 1": 3
}
}
},
{
"id": "sortBy",
"options": { "fields": [{ "displayName": "Not Ready", "desc": true }] }
}
],
"fieldConfig": {
"defaults": { "custom": { "align": "center", "displayMode": "auto" } },
"overrides": [
{
"matcher": { "id": "byName", "options": "Namespace" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 180 }]
},
{
"matcher": { "id": "byName", "options": "Endpoint" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 220 }]
},
{
"matcher": { "id": "byName", "options": "Available" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } }
]
},
{
"matcher": { "id": "byName", "options": "Not Ready" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] } }
]
}
]
},
"options": {},
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 66 }
},
{
"id": 32, "type": "row", "title": "OKD Router / Ingress (HAProxy)", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 74 }
},
{
"id": 33, "type": "timeseries", "title": "Router HTTP Request Rate by Code",
"description": "Requires HAProxy router metrics to be scraped (port 1936). OKD exposes these via the openshift-ingress ServiceMonitor.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by(code)(rate(haproxy_backend_http_responses_total[5m]))",
"refId": "A", "legendFormat": "HTTP {{code}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "HTTP 2xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "HTTP 4xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "HTTP 5xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 75 }
},
{
"id": 34, "type": "timeseries", "title": "Router 4xx + 5xx Error Rate (%)",
"description": "Client error (4xx) and server error (5xx) rates as a percentage of all requests.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(rate(haproxy_backend_http_responses_total{code=\"4xx\"}[5m])) / sum(rate(haproxy_backend_http_responses_total[5m])) * 100",
"refId": "A", "legendFormat": "4xx %"
},
{
"expr": "sum(rate(haproxy_backend_http_responses_total{code=\"5xx\"}[5m])) / sum(rate(haproxy_backend_http_responses_total[5m])) * 100",
"refId": "B", "legendFormat": "5xx %"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]}
},
"overrides": [
{ "matcher": { "id": "byName", "options": "4xx %" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "5xx %" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 75 }
},
{
"id": 35, "type": "timeseries", "title": "Router Bytes In / Out",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(rate(haproxy_frontend_bytes_in_total[5m]))",
"refId": "A", "legendFormat": "Bytes In"
},
{
"expr": "sum(rate(haproxy_frontend_bytes_out_total[5m]))",
"refId": "B", "legendFormat": "Bytes Out"
}
],
"fieldConfig": {
"defaults": {
"unit": "Bps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "Bytes In" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Bytes Out" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 83 }
},
{
"id": 36,
"type": "table",
"title": "Router Backend Server Status",
"description": "HAProxy backend servers (routes). Value 0 = DOWN, 1 = UP.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "haproxy_server_up",
"refId": "A", "instant": true, "format": "table", "legendFormat": ""
}
],
"transformations": [
{
"id": "filterFieldsByName",
"options": { "include": { "names": ["proxy", "server", "Value"] } }
},
{
"id": "organize",
"options": {
"excludeByName": {},
"renameByName": {
"proxy": "Backend",
"server": "Server",
"Value": "Status"
},
"indexByName": { "proxy": 0, "server": 1, "Value": 2 }
}
},
{
"id": "sortBy",
"options": { "fields": [{ "displayName": "Status", "desc": false }] }
}
],
"fieldConfig": {
"defaults": { "custom": { "align": "center", "displayMode": "auto" } },
"overrides": [
{
"matcher": { "id": "byName", "options": "Backend" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 260 }]
},
{
"matcher": { "id": "byName", "options": "Server" },
"properties": [{ "id": "custom.align", "value": "left" }, { "id": "custom.width", "value": 180 }]
},
{
"matcher": { "id": "byName", "options": "Status" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "mappings", "value": [
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" } } },
{ "type": "value", "options": { "1": { "text": "UP", "color": "green" } } }
]},
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]}}
]
}
]
},
"options": {},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 83 }
}
]
}

View File

@@ -1,607 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: storage-health
namespace: observability
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
json: |
{
"title": "Storage Health",
"uid": "storage-health",
"schemaVersion": 36,
"version": 1,
"refresh": "30s",
"time": { "from": "now-1h", "to": "now" },
"panels": [
{
"type": "row",
"id": 1,
"title": "PVC / PV Status",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }
},
{
"type": "stat",
"id": 2,
"title": "Bound PVCs",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(kube_persistentvolumeclaim_status_phase{phase=\"Bound\"}) or vector(0)",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "green", "value": null }]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
},
"gridPos": { "h": 5, "w": 4, "x": 0, "y": 1 }
},
{
"type": "stat",
"id": 3,
"title": "Pending PVCs",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(kube_persistentvolumeclaim_status_phase{phase=\"Pending\"}) or vector(0)",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 }
]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
},
"gridPos": { "h": 5, "w": 4, "x": 4, "y": 1 }
},
{
"type": "stat",
"id": 4,
"title": "Lost PVCs",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(kube_persistentvolumeclaim_status_phase{phase=\"Lost\"}) or vector(0)",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
},
"gridPos": { "h": 5, "w": 4, "x": 8, "y": 1 }
},
{
"type": "stat",
"id": 5,
"title": "Bound PVs / Available PVs",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(kube_persistentvolume_status_phase{phase=\"Bound\"}) or vector(0)",
"refId": "A",
"legendFormat": "Bound"
},
{
"expr": "sum(kube_persistentvolume_status_phase{phase=\"Available\"}) or vector(0)",
"refId": "B",
"legendFormat": "Available"
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "blue", "value": null }]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
},
"gridPos": { "h": 5, "w": 4, "x": 12, "y": 1 }
},
{
"type": "stat",
"id": 6,
"title": "Ceph Cluster Health",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "ceph_health_status",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 2 }
]
},
"mappings": [
{
"type": "value",
"options": {
"0": { "text": "HEALTH_OK", "index": 0 },
"1": { "text": "HEALTH_WARN", "index": 1 },
"2": { "text": "HEALTH_ERR", "index": 2 }
}
}
]
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "value"
},
"gridPos": { "h": 5, "w": 4, "x": 16, "y": 1 }
},
{
"type": "stat",
"id": 7,
"title": "OSDs Up / Total",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(ceph_osd_up) or vector(0)",
"refId": "A",
"legendFormat": "Up"
},
{
"expr": "count(ceph_osd_metadata) or vector(0)",
"refId": "B",
"legendFormat": "Total"
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "green", "value": null }]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
},
"gridPos": { "h": 5, "w": 4, "x": 20, "y": 1 }
},
{
"type": "row",
"id": 8,
"title": "Cluster Capacity",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 }
},
{
"type": "gauge",
"id": 9,
"title": "Ceph Cluster Used (%)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * (ceph_cluster_total_used_raw_bytes or ceph_cluster_total_used_bytes) / ceph_cluster_total_bytes",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"showThresholdLabels": true,
"showThresholdMarkers": true
},
"gridPos": { "h": 8, "w": 5, "x": 0, "y": 7 }
},
{
"type": "stat",
"id": 10,
"title": "Ceph Capacity — Total / Available",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "ceph_cluster_total_bytes",
"refId": "A",
"legendFormat": "Total"
},
{
"expr": "ceph_cluster_total_bytes - (ceph_cluster_total_used_raw_bytes or ceph_cluster_total_used_bytes)",
"refId": "B",
"legendFormat": "Available"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "blue", "value": null }]
}
}
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "none",
"textMode": "auto",
"orientation": "vertical"
},
"gridPos": { "h": 8, "w": 4, "x": 5, "y": 7 }
},
{
"type": "bargauge",
"id": 11,
"title": "PV Allocated Capacity by Storage Class (Bound)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum by (storageclass) (\n kube_persistentvolume_capacity_bytes\n * on(persistentvolume) group_left(storageclass)\n kube_persistentvolume_status_phase{phase=\"Bound\"}\n)",
"refId": "A",
"legendFormat": "{{storageclass}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"color": { "mode": "palette-classic" },
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "blue", "value": null }]
}
}
},
"options": {
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"] },
"displayMode": "gradient",
"showUnfilled": true
},
"gridPos": { "h": 8, "w": 7, "x": 9, "y": 7 }
},
{
"type": "piechart",
"id": 12,
"title": "PVC Phase Distribution",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "sum(kube_persistentvolumeclaim_status_phase{phase=\"Bound\"}) or vector(0)",
"refId": "A",
"legendFormat": "Bound"
},
{
"expr": "sum(kube_persistentvolumeclaim_status_phase{phase=\"Pending\"}) or vector(0)",
"refId": "B",
"legendFormat": "Pending"
},
{
"expr": "sum(kube_persistentvolumeclaim_status_phase{phase=\"Lost\"}) or vector(0)",
"refId": "C",
"legendFormat": "Lost"
}
],
"fieldConfig": {
"defaults": { "color": { "mode": "palette-classic" } }
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"pieType": "pie",
"legend": {
"displayMode": "table",
"placement": "right",
"values": ["value", "percent"]
}
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 7 }
},
{
"type": "row",
"id": 13,
"title": "Ceph Performance",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 15 }
},
{
"type": "timeseries",
"id": 14,
"title": "Ceph Pool IOPS (Read / Write)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "rate(ceph_pool_rd[5m])",
"refId": "A",
"legendFormat": "Read — pool {{pool_id}}"
},
{
"expr": "rate(ceph_pool_wr[5m])",
"refId": "B",
"legendFormat": "Write — pool {{pool_id}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "ops",
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 8 }
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }
},
{
"type": "timeseries",
"id": 15,
"title": "Ceph Pool Throughput (Read / Write)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "rate(ceph_pool_rd_bytes[5m])",
"refId": "A",
"legendFormat": "Read — pool {{pool_id}}"
},
{
"expr": "rate(ceph_pool_wr_bytes[5m])",
"refId": "B",
"legendFormat": "Write — pool {{pool_id}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "Bps",
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 8 }
}
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }
},
{
"type": "row",
"id": 16,
"title": "Ceph OSD & Pool Details",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 24 }
},
{
"type": "timeseries",
"id": 17,
"title": "Ceph Pool Space Used (%)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 * ceph_pool_bytes_used / (ceph_pool_bytes_used + ceph_pool_max_avail)",
"refId": "A",
"legendFormat": "Pool {{pool_id}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"color": { "mode": "palette-classic" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
},
"custom": { "lineWidth": 2, "fillOpacity": 10 }
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 25 }
},
{
"type": "bargauge",
"id": 18,
"title": "OSD Status per Daemon (green = Up, red = Down)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "ceph_osd_up",
"refId": "A",
"legendFormat": "{{ceph_daemon}}"
}
],
"fieldConfig": {
"defaults": {
"min": 0,
"max": 1,
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"mappings": [
{
"type": "value",
"options": {
"0": { "text": "DOWN", "index": 0 },
"1": { "text": "UP", "index": 1 }
}
}
]
}
},
"options": {
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"] },
"displayMode": "basic",
"showUnfilled": true
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 25 }
},
{
"type": "row",
"id": 19,
"title": "Node Disk Usage",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 }
},
{
"type": "timeseries",
"id": 20,
"title": "Node Root Disk Usage Over Time (%)",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 - (node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"} * 100)",
"refId": "A",
"legendFormat": "{{instance}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"color": { "mode": "palette-classic" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
},
"custom": { "lineWidth": 2, "fillOpacity": 10 }
}
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 34 }
},
{
"type": "bargauge",
"id": 21,
"title": "Current Disk Usage — All Nodes & Mountpoints",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "100 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs\"} * 100)",
"refId": "A",
"legendFormat": "{{instance}} — {{mountpoint}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
}
}
},
"options": {
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"] },
"displayMode": "gradient",
"showUnfilled": true
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 34 }
}
]
}

View File

@@ -1,744 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: okd-etcd
namespace: observability
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
json: |
{
"title": "etcd",
"uid": "okd-etcd",
"schemaVersion": 36,
"version": 1,
"refresh": "30s",
"time": { "from": "now-1h", "to": "now" },
"tags": ["okd", "etcd"],
"templating": {
"list": [
{
"name": "instance",
"type": "query",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"query": { "query": "label_values(etcd_server_has_leader, instance)", "refId": "A" },
"refresh": 2,
"includeAll": true,
"multi": true,
"allValue": ".*",
"label": "Instance",
"sort": 1,
"current": {},
"options": []
}
]
},
"panels": [
{
"id": 1, "type": "stat", "title": "Cluster Members",
"description": "Total number of etcd members currently reporting metrics.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(etcd_server_has_leader)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "green", "value": 3 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }
},
{
"id": 2, "type": "stat", "title": "Has Leader",
"description": "min() across all members. 0 = at least one member has no quorum — cluster is degraded.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "min(etcd_server_has_leader)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]},
"unit": "short", "noValue": "0",
"mappings": [
{ "type": "value", "options": {
"0": { "text": "NO LEADER", "color": "red" },
"1": { "text": "OK", "color": "green" }
}}
]
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }
},
{
"id": 3, "type": "stat", "title": "Leader Changes (1h)",
"description": "Number of leader elections in the last hour. ≥3 indicates cluster instability.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "sum(changes(etcd_server_leader_changes_seen_total[1h]))", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 3 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }
},
{
"id": 4, "type": "stat", "title": "DB Size (Max)",
"description": "Largest boltdb file size across all members. Default etcd quota is 8 GiB.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "max(etcd_mvcc_db_total_size_in_bytes)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 2147483648 },
{ "color": "orange", "value": 5368709120 },
{ "color": "red", "value": 7516192768 }
]},
"unit": "bytes", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }
},
{
"id": 5, "type": "stat", "title": "DB Fragmentation (Max)",
"description": "% of DB space that is allocated but unused. >50% → run etcdctl defrag.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "max((etcd_mvcc_db_total_size_in_bytes - etcd_mvcc_db_total_size_in_use_in_bytes) / etcd_mvcc_db_total_size_in_bytes * 100)",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 25 },
{ "color": "orange", "value": 50 },
{ "color": "red", "value": 75 }
]},
"unit": "percent", "noValue": "0", "decimals": 1
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }
},
{
"id": 6, "type": "stat", "title": "Failed Proposals/s",
"description": "Rate of rejected Raft proposals. Any sustained non-zero value = cluster health problem.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "sum(rate(etcd_server_proposals_failed_total[5m]))", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 0.001 }
]},
"unit": "short", "noValue": "0", "decimals": 3
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }
},
{
"id": 7, "type": "stat", "title": "WAL Fsync p99",
"description": "99th percentile WAL flush-to-disk time. >10ms is concerning; >100ms = serious I/O bottleneck.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "histogram_quantile(0.99, sum(rate(etcd_disk_wal_fsync_duration_seconds_bucket[5m])) by (le))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.01 },
{ "color": "orange", "value": 0.1 },
{ "color": "red", "value": 0.5 }
]},
"unit": "s", "noValue": "0", "decimals": 4
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 0 }
},
{
"id": 8, "type": "stat", "title": "Backend Commit p99",
"description": "99th percentile boltdb commit time. >25ms = warning; >100ms = critical backend I/O pressure.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "histogram_quantile(0.99, sum(rate(etcd_disk_backend_commit_duration_seconds_bucket[5m])) by (le))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.025 },
{ "color": "orange", "value": 0.1 },
{ "color": "red", "value": 0.25 }
]},
"unit": "s", "noValue": "0", "decimals": 4
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 21, "y": 0 }
},
{
"id": 9, "type": "row", "title": "Cluster Health", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 4 }
},
{
"id": 10, "type": "timeseries", "title": "Has Leader per Instance",
"description": "1 = member has a leader; 0 = member lost quorum. A dip to 0 marks the exact moment of a leader election.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "etcd_server_has_leader{instance=~\"$instance\"}",
"refId": "A", "legendFormat": "{{instance}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0, "max": 1.1,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 20, "showPoints": "never", "spanNulls": false },
"mappings": [
{ "type": "value", "options": {
"0": { "text": "0 — no leader" },
"1": { "text": "1 — ok" }
}}
]
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "none" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": [] }
},
"gridPos": { "h": 6, "w": 8, "x": 0, "y": 5 }
},
{
"id": 11, "type": "timeseries", "title": "Leader Changes (cumulative)",
"description": "Monotonically increasing counter per member. A step jump = one leader election. Correlated jumps across members = cluster-wide event.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "etcd_server_leader_changes_seen_total{instance=~\"$instance\"}",
"refId": "A", "legendFormat": "{{instance}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "auto", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "none" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull"] }
},
"gridPos": { "h": 6, "w": 8, "x": 8, "y": 5 }
},
{
"id": 12, "type": "timeseries", "title": "Slow Operations",
"description": "slow_apply: proposals applied slower than expected. slow_read_index: linearizable reads timing out. heartbeat_failures: Raft heartbeat send errors (network partition indicator).",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "rate(etcd_server_slow_apply_total{instance=~\"$instance\"}[5m])", "refId": "A", "legendFormat": "Slow Apply — {{instance}}" },
{ "expr": "rate(etcd_server_slow_read_indexes_total{instance=~\"$instance\"}[5m])", "refId": "B", "legendFormat": "Slow Read Index — {{instance}}" },
{ "expr": "rate(etcd_server_heartbeat_send_failures_total{instance=~\"$instance\"}[5m])", "refId": "C", "legendFormat": "Heartbeat Failures — {{instance}}" }
],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 6, "w": 8, "x": 16, "y": 5 }
},
{
"id": 13, "type": "row", "title": "gRPC Traffic", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 11 }
},
{
"id": 14, "type": "timeseries", "title": "gRPC Request Rate by Method",
"description": "Unary calls/s per RPC method. High Put/Txn = heavy write load. High Range = heavy read load. High Watch = many controller watchers.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(grpc_method)(rate(grpc_server_started_total{job=~\".*etcd.*\",grpc_type=\"unary\"}[5m]))",
"refId": "A", "legendFormat": "{{grpc_method}}"
}],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 12 }
},
{
"id": 15, "type": "timeseries", "title": "gRPC Error Rate by Status Code",
"description": "Non-OK responses by gRPC status code. RESOURCE_EXHAUSTED = overloaded. UNAVAILABLE = leader election. DEADLINE_EXCEEDED = latency spike.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(grpc_code)(rate(grpc_server_handled_total{job=~\".*etcd.*\",grpc_code!=\"OK\"}[5m]))",
"refId": "A", "legendFormat": "{{grpc_code}}"
}],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 12 }
},
{
"id": 16, "type": "timeseries", "title": "gRPC Request Latency (p50 / p95 / p99)",
"description": "Unary call handling duration. p99 > 100ms for Put/Txn indicates disk or CPU pressure. p99 > 500ms will cause kube-apiserver timeouts.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "histogram_quantile(0.50, sum(rate(grpc_server_handling_seconds_bucket{job=~\".*etcd.*\",grpc_type=\"unary\"}[5m])) by (le))", "refId": "A", "legendFormat": "p50" },
{ "expr": "histogram_quantile(0.95, sum(rate(grpc_server_handling_seconds_bucket{job=~\".*etcd.*\",grpc_type=\"unary\"}[5m])) by (le))", "refId": "B", "legendFormat": "p95" },
{ "expr": "histogram_quantile(0.99, sum(rate(grpc_server_handling_seconds_bucket{job=~\".*etcd.*\",grpc_type=\"unary\"}[5m])) by (le))", "refId": "C", "legendFormat": "p99" }
],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "p50" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p95" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p99" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 }
},
{
"id": 17, "type": "row", "title": "Raft Proposals", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 20 }
},
{
"id": 18, "type": "timeseries", "title": "Proposals Committed vs Applied",
"description": "Committed = agreed by Raft quorum. Applied = persisted to boltdb. A widening gap between the two = backend apply backlog (disk too slow to keep up).",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "rate(etcd_server_proposals_committed_total{instance=~\"$instance\"}[5m])", "refId": "A", "legendFormat": "Committed — {{instance}}" },
{ "expr": "rate(etcd_server_proposals_applied_total{instance=~\"$instance\"}[5m])", "refId": "B", "legendFormat": "Applied — {{instance}}" }
],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 0, "y": 21 }
},
{
"id": 19, "type": "timeseries", "title": "Proposals Pending",
"description": "In-flight Raft proposals not yet committed. Consistently high (>5) = cluster cannot keep up with write throughput.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "etcd_server_proposals_pending{instance=~\"$instance\"}",
"refId": "A", "legendFormat": "{{instance}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false,
"thresholdsStyle": { "mode": "line+area" }
},
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 5 },
{ "color": "red", "value": 10 }
]}
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 8, "y": 21 }
},
{
"id": 20, "type": "timeseries", "title": "Failed Proposals Rate",
"description": "Raft proposals that were rejected. Root causes: quorum loss, leader timeout, network partition between members.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "rate(etcd_server_proposals_failed_total{instance=~\"$instance\"}[5m])",
"refId": "A", "legendFormat": "{{instance}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2, "fillOpacity": 20, "showPoints": "never", "spanNulls": false,
"thresholdsStyle": { "mode": "line" }
},
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 0.001 }
]}
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 16, "y": 21 }
},
{
"id": 21, "type": "row", "title": "Disk I/O", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 28 }
},
{
"id": 22, "type": "timeseries", "title": "WAL Fsync Duration (p50 / p95 / p99) per Instance",
"description": "Time to flush the write-ahead log to disk. etcd is extremely sensitive to WAL latency. >10ms p99 = storage is the bottleneck. Correlates directly with Raft commit latency.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "histogram_quantile(0.50, sum by(le,instance)(rate(etcd_disk_wal_fsync_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "A", "legendFormat": "p50 — {{instance}}" },
{ "expr": "histogram_quantile(0.95, sum by(le,instance)(rate(etcd_disk_wal_fsync_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "B", "legendFormat": "p95 — {{instance}}" },
{ "expr": "histogram_quantile(0.99, sum by(le,instance)(rate(etcd_disk_wal_fsync_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "C", "legendFormat": "p99 — {{instance}}" }
],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 29 }
},
{
"id": 23, "type": "timeseries", "title": "Backend Commit Duration (p50 / p95 / p99) per Instance",
"description": "Time for boltdb to commit a batch transaction. A spike here while WAL is healthy = backend I/O saturation or boltdb lock contention. Triggers apply backlog.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "histogram_quantile(0.50, sum by(le,instance)(rate(etcd_disk_backend_commit_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "A", "legendFormat": "p50 — {{instance}}" },
{ "expr": "histogram_quantile(0.95, sum by(le,instance)(rate(etcd_disk_backend_commit_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "B", "legendFormat": "p95 — {{instance}}" },
{ "expr": "histogram_quantile(0.99, sum by(le,instance)(rate(etcd_disk_backend_commit_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "C", "legendFormat": "p99 — {{instance}}" }
],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 }
},
{
"id": 24, "type": "row", "title": "Network (Peer & Client)", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 37 }
},
{
"id": 25, "type": "timeseries", "title": "Peer RX Rate",
"description": "Bytes received from Raft peers (log replication + heartbeats). A burst during a quiet period = large snapshot being streamed to a recovering member.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "rate(etcd_network_peer_received_bytes_total{instance=~\"$instance\"}[5m])",
"refId": "A", "legendFormat": "{{instance}}"
}],
"fieldConfig": {
"defaults": {
"unit": "Bps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 6, "x": 0, "y": 38 }
},
{
"id": 26, "type": "timeseries", "title": "Peer TX Rate",
"description": "Bytes sent to Raft peers. Leader will have higher TX than followers (it replicates entries to all members).",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "rate(etcd_network_peer_sent_bytes_total{instance=~\"$instance\"}[5m])",
"refId": "A", "legendFormat": "{{instance}}"
}],
"fieldConfig": {
"defaults": {
"unit": "Bps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 6, "x": 6, "y": 38 }
},
{
"id": 27, "type": "timeseries", "title": "Client gRPC Received",
"description": "Bytes received from API clients (kube-apiserver, operators). Spike = large write burst from controllers or kubectl apply.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "rate(etcd_network_client_grpc_received_bytes_total{instance=~\"$instance\"}[5m])",
"refId": "A", "legendFormat": "{{instance}}"
}],
"fieldConfig": {
"defaults": {
"unit": "Bps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 6, "x": 12, "y": 38 }
},
{
"id": 28, "type": "timeseries", "title": "Client gRPC Sent",
"description": "Bytes sent to API clients (responses + watch events). Persistently high = many active Watch streams or large objects being served.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "rate(etcd_network_client_grpc_sent_bytes_total{instance=~\"$instance\"}[5m])",
"refId": "A", "legendFormat": "{{instance}}"
}],
"fieldConfig": {
"defaults": {
"unit": "Bps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 6, "x": 18, "y": 38 }
},
{
"id": 29, "type": "row", "title": "DB Size & Process Resources", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 45 }
},
{
"id": 30, "type": "timeseries", "title": "DB Total vs In-Use Size per Instance",
"description": "Total = allocated boltdb file size. In Use = live key data. The gap between them = fragmentation. Steady growth of Total = compaction not keeping up with key churn.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "etcd_mvcc_db_total_size_in_bytes{instance=~\"$instance\"}", "refId": "A", "legendFormat": "Total — {{instance}}" },
{ "expr": "etcd_mvcc_db_total_size_in_use_in_bytes{instance=~\"$instance\"}", "refId": "B", "legendFormat": "In Use — {{instance}}" }
],
"fieldConfig": {
"defaults": {
"unit": "bytes", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 46 }
},
{
"id": 31, "type": "timeseries", "title": "Process Resident Memory (RSS)",
"description": "Physical RAM consumed by the etcd process. Monotonically growing RSS = memory leak or oversized watch cache. Typical healthy range: 500 MiB2 GiB depending on cluster size.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "etcd_process_resident_memory_bytes{instance=~\"$instance\"}",
"refId": "A", "legendFormat": "{{instance}}"
}],
"fieldConfig": {
"defaults": {
"unit": "bytes", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 46 }
},
{
"id": 32, "type": "timeseries", "title": "Open File Descriptors vs Limit",
"description": "Open FD count (solid) and process FD limit (dashed). Approaching the limit will cause WAL file creation and new client connections to fail.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "etcd_process_open_fds{instance=~\"$instance\"}", "refId": "A", "legendFormat": "Open — {{instance}}" },
{ "expr": "etcd_process_max_fds{instance=~\"$instance\"}", "refId": "B", "legendFormat": "Limit — {{instance}}" }
],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{
"matcher": { "id": "byRegexp", "options": "^Limit.*" },
"properties": [
{ "id": "custom.lineWidth", "value": 1 },
{ "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [6, 4] } },
{ "id": "custom.fillOpacity","value": 0 }
]
}
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 46 }
},
{
"id": 33, "type": "row", "title": "Snapshots", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 54 }
},
{
"id": 34, "type": "timeseries", "title": "Snapshot Save Duration (p50 / p95 / p99)",
"description": "Time to write a full snapshot of the boltdb to disk. Slow saves delay Raft log compaction, causing the WAL to grow unboundedly and members to fall further behind.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "histogram_quantile(0.50, sum by(le)(rate(etcd_debugging_snap_save_total_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "A", "legendFormat": "p50" },
{ "expr": "histogram_quantile(0.95, sum by(le)(rate(etcd_debugging_snap_save_total_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "B", "legendFormat": "p95" },
{ "expr": "histogram_quantile(0.99, sum by(le)(rate(etcd_debugging_snap_save_total_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "C", "legendFormat": "p99" }
],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "p50" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p95" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p99" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 12, "x": 0, "y": 55 }
},
{
"id": 35, "type": "timeseries", "title": "Snapshot DB Fsync Duration (p50 / p95 / p99)",
"description": "Time to fsync the snapshot file itself. Distinct from WAL fsync: this is flushing the entire boltdb copy to disk after a snapshot is taken.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "histogram_quantile(0.50, sum by(le)(rate(etcd_snap_db_fsync_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "A", "legendFormat": "p50" },
{ "expr": "histogram_quantile(0.95, sum by(le)(rate(etcd_snap_db_fsync_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "B", "legendFormat": "p95" },
{ "expr": "histogram_quantile(0.99, sum by(le)(rate(etcd_snap_db_fsync_duration_seconds_bucket{instance=~\"$instance\"}[5m])))", "refId": "C", "legendFormat": "p99" }
],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "p50" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p95" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p99" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 12, "x": 12, "y": 55 }
}
]
}

View File

@@ -1,752 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: okd-control-plane-health
namespace: observability
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
json: |
{
"title": "Control Plane Health",
"uid": "okd-control-plane",
"schemaVersion": 36,
"version": 1,
"refresh": "30s",
"time": { "from": "now-1h", "to": "now" },
"tags": ["okd", "control-plane"],
"templating": {
"list": [
{
"name": "instance",
"type": "query",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"query": { "query": "label_values(apiserver_request_total, instance)", "refId": "A" },
"refresh": 2,
"includeAll": true,
"multi": true,
"allValue": ".*",
"label": "API Server Instance",
"sort": 1,
"current": {},
"options": []
}
]
},
"panels": [
{
"id": 1, "type": "stat", "title": "API Servers Up",
"description": "Number of kube-apiserver instances currently scraped and up. Healthy HA cluster = 3.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(up{job=~\".*apiserver.*\"} == 1)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "green", "value": 3 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }
},
{
"id": 2, "type": "stat", "title": "Controller Managers Up",
"description": "kube-controller-manager instances up. In OKD only one holds the leader lease at a time; others are hot standbys.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(up{job=~\".*controller-manager.*\"} == 1)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "green", "value": 3 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }
},
{
"id": 3, "type": "stat", "title": "Schedulers Up",
"description": "kube-scheduler instances up. One holds the leader lease; rest are standbys. 0 = no scheduling of new pods.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(up{job=~\".*scheduler.*\"} == 1)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "green", "value": 3 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }
},
{
"id": 4, "type": "stat", "title": "API 5xx Rate",
"description": "Server-side errors (5xx) across all apiserver instances per second. Any sustained non-zero value = apiserver internal fault.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "sum(rate(apiserver_request_total{code=~\"5..\"}[5m]))", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.01 },
{ "color": "red", "value": 1 }
]},
"unit": "reqps", "noValue": "0", "decimals": 3
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }
},
{
"id": 5, "type": "stat", "title": "Inflight — Mutating",
"description": "Current in-flight mutating requests (POST/PUT/PATCH/DELETE). Default OKD limit is ~1000. Hitting the limit = 429 errors for writes.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "sum(apiserver_current_inflight_requests{request_kind=\"mutating\"})", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 500 },
{ "color": "orange", "value": 750 },
{ "color": "red", "value": 900 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }
},
{
"id": 6, "type": "stat", "title": "Inflight — Read-Only",
"description": "Current in-flight non-mutating requests (GET/LIST/WATCH). Default OKD limit is ~3000. Hitting it = 429 for reads, impacting controllers and kubectl.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "sum(apiserver_current_inflight_requests{request_kind=\"readOnly\"})", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1500 },
{ "color": "orange", "value": 2200 },
{ "color": "red", "value": 2700 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }
},
{
"id": 7, "type": "stat", "title": "API Request p99 (non-WATCH)",
"description": "Overall p99 latency for all non-streaming verbs. >1s = noticeable kubectl sluggishness. >10s = controllers timing out on LIST/GET.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "histogram_quantile(0.99, sum(rate(apiserver_request_duration_seconds_bucket{verb!~\"WATCH|CONNECT\"}[5m])) by (le))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "orange", "value": 1 },
{ "color": "red", "value": 5 }
]},
"unit": "s", "noValue": "0", "decimals": 3
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 0 }
},
{
"id": 8, "type": "stat", "title": "APIServer → etcd p99",
"description": "p99 time apiserver spends waiting on etcd calls. Spike here while WAL fsync is healthy = serialization or large object overhead.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "histogram_quantile(0.99, sum(rate(apiserver_storage_request_duration_seconds_bucket[5m])) by (le))",
"refId": "A", "legendFormat": ""
}],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.05 },
{ "color": "orange", "value": 0.2 },
{ "color": "red", "value": 0.5 }
]},
"unit": "s", "noValue": "0", "decimals": 4
}
},
"options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 21, "y": 0 }
},
{
"id": 9, "type": "row", "title": "API Server — Request Rates & Errors", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 4 }
},
{
"id": 10, "type": "timeseries", "title": "Request Rate by Verb",
"description": "Non-streaming calls per second broken down by verb. GET/LIST = read load from controllers. POST/PUT/PATCH/DELETE = write throughput. A sudden LIST spike = controller cache resync storm.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(verb)(rate(apiserver_request_total{instance=~\"$instance\",verb!~\"WATCH|CONNECT\"}[5m]))",
"refId": "A", "legendFormat": "{{verb}}"
}],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 5 }
},
{
"id": 11, "type": "timeseries", "title": "Error Rate by HTTP Status Code",
"description": "4xx/5xx responses per second by code. 429 = inflight limit hit (throttling). 422 = admission rejection or invalid object. 500/503 = internal apiserver fault or etcd unavailability.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(code)(rate(apiserver_request_total{instance=~\"$instance\",code=~\"[45]..\"}[5m]))",
"refId": "A", "legendFormat": "HTTP {{code}}"
}],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 5 }
},
{
"id": 12, "type": "timeseries", "title": "In-Flight Requests — Mutating vs Read-Only",
"description": "Instantaneous count of requests being actively handled. The two series correspond to the two inflight limit buckets enforced by the apiserver's Priority and Fairness (APF) or legacy inflight settings.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "sum by(request_kind)(apiserver_current_inflight_requests{instance=~\"$instance\"})", "refId": "A", "legendFormat": "{{request_kind}}" }
],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 20, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 5 }
},
{
"id": 13, "type": "row", "title": "API Server — Latency", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 13 }
},
{
"id": 14, "type": "timeseries", "title": "Request Latency — p50 / p95 / p99 (non-WATCH)",
"description": "Aggregated end-to-end request duration across all verbs except WATCH/CONNECT (which are unbounded streaming). A rising p99 without a matching rise in etcd latency = CPU saturation, admission webhook slowness, or serialization overhead.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "histogram_quantile(0.50, sum(rate(apiserver_request_duration_seconds_bucket{instance=~\"$instance\",verb!~\"WATCH|CONNECT\"}[5m])) by (le))", "refId": "A", "legendFormat": "p50" },
{ "expr": "histogram_quantile(0.95, sum(rate(apiserver_request_duration_seconds_bucket{instance=~\"$instance\",verb!~\"WATCH|CONNECT\"}[5m])) by (le))", "refId": "B", "legendFormat": "p95" },
{ "expr": "histogram_quantile(0.99, sum(rate(apiserver_request_duration_seconds_bucket{instance=~\"$instance\",verb!~\"WATCH|CONNECT\"}[5m])) by (le))", "refId": "C", "legendFormat": "p99" }
],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "p50" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p95" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p99" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 14 }
},
{
"id": 15, "type": "timeseries", "title": "Request p99 Latency by Verb",
"description": "p99 latency broken out per verb. LIST is inherently slower than GET due to serializing full collections. A POST/PUT spike = heavy admission webhook chain or large object writes. DELETE spikes are usually caused by cascading GC finalizer storms.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "histogram_quantile(0.99, sum by(verb,le)(rate(apiserver_request_duration_seconds_bucket{instance=~\"$instance\",verb!~\"WATCH|CONNECT\"}[5m])))",
"refId": "A", "legendFormat": "{{verb}}"
}],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 14 }
},
{
"id": 16, "type": "timeseries", "title": "APIServer → etcd Latency by Operation",
"description": "Time apiserver spends waiting on etcd, split by operation type (get, list, create, update, delete, watch). Elevated get/list = etcd read pressure. Elevated create/update = write bottleneck, likely correlated with WAL fsync latency.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "histogram_quantile(0.50, sum by(operation,le)(rate(apiserver_storage_request_duration_seconds_bucket[5m])))", "refId": "A", "legendFormat": "p50 — {{operation}}" },
{ "expr": "histogram_quantile(0.99, sum by(operation,le)(rate(apiserver_storage_request_duration_seconds_bucket[5m])))", "refId": "B", "legendFormat": "p99 — {{operation}}" }
],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 14 }
},
{
"id": 17, "type": "row", "title": "API Server — Watches & Long-Running Requests", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 }
},
{
"id": 18, "type": "timeseries", "title": "Active Long-Running Requests (Watches) by Resource",
"description": "Instantaneous count of open WATCH streams grouped by resource. Each controller typically holds one WATCH per resource type per apiserver instance. A sudden drop = controller restart; a runaway climb = operator creating watches without cleanup.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(resource)(apiserver_longrunning_requests{instance=~\"$instance\",verb=\"WATCH\"})",
"refId": "A", "legendFormat": "{{resource}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 0, "y": 23 }
},
{
"id": 19, "type": "timeseries", "title": "Watch Events Dispatched Rate by Kind",
"description": "Watch events sent to all active watchers per second, by object kind. Persistent high rate for a specific kind = that resource type is churning heavily, increasing etcd load and controller reconcile frequency.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(kind)(rate(apiserver_watch_events_total{instance=~\"$instance\"}[5m]))",
"refId": "A", "legendFormat": "{{kind}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 8, "y": 23 }
},
{
"id": 20, "type": "timeseries", "title": "Watch Event Size — p50 / p95 / p99 by Kind",
"description": "Size of individual watch events dispatched to clients. Large events (MiB-scale) for Secrets or ConfigMaps = objects being stored with oversized data. Contributes to apiserver memory pressure and network saturation.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "histogram_quantile(0.50, sum by(kind,le)(rate(apiserver_watch_events_sizes_bucket{instance=~\"$instance\"}[5m])))", "refId": "A", "legendFormat": "p50 — {{kind}}" },
{ "expr": "histogram_quantile(0.99, sum by(kind,le)(rate(apiserver_watch_events_sizes_bucket{instance=~\"$instance\"}[5m])))", "refId": "B", "legendFormat": "p99 — {{kind}}" }
],
"fieldConfig": {
"defaults": {
"unit": "bytes", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 16, "y": 23 }
},
{
"id": 21, "type": "row", "title": "Admission Webhooks", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 30 }
},
{
"id": 22, "type": "timeseries", "title": "Webhook Call Rate by Name",
"description": "Mutating and validating admission webhook invocations per second by webhook name. A webhook invoked on every write (e.g., a mutating webhook with no object selector) can be a major source of write latency amplification.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(name,type)(rate(apiserver_admission_webhook_request_total{instance=~\"$instance\"}[5m]))",
"refId": "A", "legendFormat": "{{type}} — {{name}}"
}],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 0, "y": 31 }
},
{
"id": 23, "type": "timeseries", "title": "Webhook Latency p99 by Name",
"description": "p99 round-trip time per webhook call (network + webhook server processing). Default apiserver timeout is 10s; a webhook consistently near that limit causes cascading write latency for all resources it intercepts.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "histogram_quantile(0.99, sum by(name,le)(rate(apiserver_admission_webhook_admission_duration_seconds_bucket{instance=~\"$instance\"}[5m])))",
"refId": "A", "legendFormat": "{{name}}"
}],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "red", "value": 2.0 }
]}
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 8, "y": 31 }
},
{
"id": 24, "type": "timeseries", "title": "Webhook Rejection Rate by Name",
"description": "Rate of admission denials per webhook. A validating webhook rejecting requests is expected behaviour; a sudden surge indicates either a newly enforced policy or a misbehaving webhook rejecting valid objects.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(name,error_type)(rate(apiserver_admission_webhook_rejection_count{instance=~\"$instance\"}[5m]))",
"refId": "A", "legendFormat": "{{name}} ({{error_type}})"
}],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 16, "y": 31 }
},
{
"id": 25, "type": "row", "title": "kube-controller-manager", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 38 }
},
{
"id": 26, "type": "timeseries", "title": "Work Queue Depth by Controller",
"description": "Items waiting to be reconciled in each controller's work queue. Persistent non-zero depth = controller cannot keep up with the event rate. Identifies which specific controller is the bottleneck during overload incidents.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "topk(15, sum by(name)(workqueue_depth{job=~\".*controller-manager.*\"}))",
"refId": "A", "legendFormat": "{{name}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false,
"thresholdsStyle": { "mode": "line" }
},
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 10 },
{ "color": "red", "value": 50 }
]}
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 39 }
},
{
"id": 27, "type": "timeseries", "title": "Work Queue Item Processing Duration p99 by Controller",
"description": "p99 time a work item spends being actively reconciled (inside the reconcile loop, excludes queue wait time). A slow reconcile = either the controller is doing expensive API calls or the etcd write path is slow.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "histogram_quantile(0.99, sum by(name,le)(rate(workqueue_work_duration_seconds_bucket{job=~\".*controller-manager.*\"}[5m])))",
"refId": "A", "legendFormat": "{{name}}"
}],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 39 }
},
{
"id": 28, "type": "timeseries", "title": "Work Queue Retry Rate by Controller",
"description": "Rate of items being re-queued after a failed reconciliation. A persistently high retry rate for a controller = it is encountering recurring errors on the same objects (e.g., API permission errors, webhook rejections, or resource conflicts).",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "topk(15, sum by(name)(rate(workqueue_retries_total{job=~\".*controller-manager.*\"}[5m])))",
"refId": "A", "legendFormat": "{{name}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 39 }
},
{
"id": 29, "type": "row", "title": "kube-scheduler", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 47 }
},
{
"id": 30, "type": "timeseries", "title": "Scheduling Attempt Rate by Result",
"description": "Outcomes of scheduling cycles per second. scheduled = pod successfully bound to a node. unschedulable = no node met the pod's constraints. error = scheduler internal failure (API error, timeout). Persistent unschedulable = cluster capacity or taints/affinity misconfiguration.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(result)(rate(scheduler_schedule_attempts_total[5m]))",
"refId": "A", "legendFormat": "{{result}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "scheduled" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "unschedulable" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 0, "y": 48 }
},
{
"id": 31, "type": "timeseries", "title": "Scheduling Latency — p50 / p95 / p99",
"description": "Time from when a pod enters the active queue to when a binding decision is made (does not include bind API call time). Includes filter, score, and reserve plugin execution time. Spike = expensive affinity rules, large number of nodes, or slow extender webhooks.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "histogram_quantile(0.50, sum(rate(scheduler_scheduling_attempt_duration_seconds_bucket[5m])) by (le))", "refId": "A", "legendFormat": "p50" },
{ "expr": "histogram_quantile(0.95, sum(rate(scheduler_scheduling_attempt_duration_seconds_bucket[5m])) by (le))", "refId": "B", "legendFormat": "p95" },
{ "expr": "histogram_quantile(0.99, sum(rate(scheduler_scheduling_attempt_duration_seconds_bucket[5m])) by (le))", "refId": "C", "legendFormat": "p99" }
],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "p50" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p95" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "p99" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 8, "y": 48 }
},
{
"id": 32, "type": "timeseries", "title": "Pending Pods by Queue",
"description": "Pods waiting to be scheduled, split by internal queue. active = ready to be attempted now. backoff = recently failed, in exponential back-off. unschedulable = parked until cluster state changes. A growing unschedulable queue = systemic capacity or constraint problem.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(queue)(scheduler_pending_pods)",
"refId": "A", "legendFormat": "{{queue}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false,
"thresholdsStyle": { "mode": "line" }
},
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 10 },
{ "color": "red", "value": 50 }
]}
},
"overrides": [
{ "matcher": { "id": "byName", "options": "unschedulable" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "backoff" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "active" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 16, "y": 48 }
},
{
"id": 33, "type": "row", "title": "Process Resources — All Control Plane Components", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 55 }
},
{
"id": 34, "type": "timeseries", "title": "CPU Usage by Component",
"description": "Rate of CPU seconds consumed by each control plane process. apiserver CPU spike = surge in request volume or list serialization. controller-manager CPU spike = reconcile storm. scheduler CPU spike = large node count with complex affinity.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "sum by(job)(rate(process_cpu_seconds_total{job=~\".*apiserver.*\"}[5m]))", "refId": "A", "legendFormat": "apiserver — {{job}}" },
{ "expr": "sum by(job)(rate(process_cpu_seconds_total{job=~\".*controller-manager.*\"}[5m]))", "refId": "B", "legendFormat": "controller-manager — {{job}}" },
{ "expr": "sum by(job)(rate(process_cpu_seconds_total{job=~\".*scheduler.*\"}[5m]))", "refId": "C", "legendFormat": "scheduler — {{job}}" }
],
"fieldConfig": {
"defaults": {
"unit": "percentunit", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 0, "y": 56 }
},
{
"id": 35, "type": "timeseries", "title": "RSS Memory by Component",
"description": "Resident set size of each control plane process. apiserver memory is dominated by the watch cache size and serialisation buffers. controller-manager memory = informer caches. Monotonically growing RSS without restarts = memory leak or unbounded cache growth.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "sum by(job)(process_resident_memory_bytes{job=~\".*apiserver.*\"})", "refId": "A", "legendFormat": "apiserver — {{job}}" },
{ "expr": "sum by(job)(process_resident_memory_bytes{job=~\".*controller-manager.*\"})", "refId": "B", "legendFormat": "controller-manager — {{job}}" },
{ "expr": "sum by(job)(process_resident_memory_bytes{job=~\".*scheduler.*\"})", "refId": "C", "legendFormat": "scheduler — {{job}}" }
],
"fieldConfig": {
"defaults": {
"unit": "bytes", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 8, "y": 56 }
},
{
"id": 36, "type": "timeseries", "title": "Goroutines by Component",
"description": "Number of live goroutines in each control plane process. Gradual upward drift = goroutine leak (often tied to unclosed watch streams or context leaks). A step-down = process restart. apiserver typically runs 200600 goroutines; spikes above 1000 warrant investigation.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "sum by(job)(go_goroutines{job=~\".*apiserver.*\"})", "refId": "A", "legendFormat": "apiserver — {{job}}" },
{ "expr": "sum by(job)(go_goroutines{job=~\".*controller-manager.*\"})", "refId": "B", "legendFormat": "controller-manager — {{job}}" },
{ "expr": "sum by(job)(go_goroutines{job=~\".*scheduler.*\"})", "refId": "C", "legendFormat": "scheduler — {{job}}" }
],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 5, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 16, "y": 56 }
}
]
}

View File

@@ -1,741 +0,0 @@
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: okd-alerts-events
namespace: observability
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
json: |
{
"title": "Alerts & Events — Active Problems",
"uid": "okd-alerts-events",
"schemaVersion": 36,
"version": 1,
"refresh": "30s",
"time": { "from": "now-3h", "to": "now" },
"tags": ["okd", "alerts", "events"],
"templating": {
"list": [
{
"name": "severity",
"type": "custom",
"label": "Severity Filter",
"query": "critical,warning,info",
"current": { "selected": true, "text": "All", "value": "$__all" },
"includeAll": true,
"allValue": "critical|warning|info",
"multi": false,
"options": [
{ "selected": true, "text": "All", "value": "$__all" },
{ "selected": false, "text": "Critical", "value": "critical" },
{ "selected": false, "text": "Warning", "value": "warning" },
{ "selected": false, "text": "Info", "value": "info" }
]
},
{
"name": "namespace",
"type": "query",
"label": "Namespace",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"query": { "query": "label_values(ALERTS{alertstate=\"firing\"}, namespace)", "refId": "A" },
"refresh": 2,
"includeAll": true,
"allValue": ".*",
"multi": true,
"sort": 1,
"current": {},
"options": []
}
]
},
"panels": [
{
"id": 1, "type": "stat", "title": "Critical Alerts Firing",
"description": "Alerting rule instances currently in the firing state with severity=\"critical\". Any non-zero value represents a breached SLO or infrastructure condition requiring immediate on-call response. The ALERTS metric is generated by Prometheus directly from your alerting rules — it reflects what Prometheus knows, before Alertmanager routing or silencing.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(ALERTS{alertstate=\"firing\",severity=\"critical\"}) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }
},
{
"id": 2, "type": "stat", "title": "Warning Alerts Firing",
"description": "Firing alerts at severity=\"warning\". Warnings indicate a degraded or elevated-risk condition that has not yet crossed the critical threshold. A sustained or growing warning count often precedes a critical fire — treat them as early-warning signals, not background noise.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(ALERTS{alertstate=\"firing\",severity=\"warning\"}) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "orange", "value": 5 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }
},
{
"id": 3, "type": "stat", "title": "Info / Unclassified Alerts Firing",
"description": "Firing alerts with severity=\"info\" or no severity label. These are informational and do not normally require immediate action. A sudden large jump may reveal noisy alerting rules generating alert fatigue — rules worth reviewing for threshold tuning or adding inhibition rules.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(ALERTS{alertstate=\"firing\",severity!~\"critical|warning\"}) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "blue", "value": 1 },
{ "color": "blue", "value": 25 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }
},
{
"id": 4, "type": "stat", "title": "Alerts Silenced (Suppressed)",
"description": "Alerts currently matched by an active Alertmanager silence rule and therefore not routed to receivers. Silences are intentional during maintenance windows, but a large suppressed count outside of planned maintenance = an overly broad silence masking real problems. Zero silences when a maintenance window is active = the silence has expired or was misconfigured.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "sum(alertmanager_alerts{state=\"suppressed\"}) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 20 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }
},
{
"id": 5, "type": "stat", "title": "CrashLoopBackOff Pods",
"description": "Container instances currently waiting in the CrashLoopBackOff state — the container crashed and Kubernetes is retrying with exponential back-off. Each instance is a pod that cannot stay running. Common root causes: OOM kill, bad entrypoint, missing Secret or ConfigMap, an unavailable init dependency, or a broken image layer.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_pod_container_status_waiting_reason{reason=\"CrashLoopBackOff\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 3 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }
},
{
"id": 6, "type": "stat", "title": "OOMKilled Containers",
"description": "Containers whose most recent termination reason was OOMKilled. This is a current-state snapshot: a container that was OOMKilled, restarted, and is now Running will still appear here until its next termination occurs for a different reason. Non-zero and stable = recurring OOM, likely a workload memory leak or under-provisioned memory limit.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_pod_container_status_last_terminated_reason{reason=\"OOMKilled\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 1 },
{ "color": "red", "value": 5 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }
},
{
"id": 7, "type": "stat", "title": "NotReady Nodes",
"description": "Nodes where the Ready condition is currently not True (False or Unknown). A NotReady node stops receiving new pod scheduling and, after the node eviction timeout (~5 min default), pods on it will be evicted. Control plane nodes going NotReady simultaneously = potential quorum loss. Any non-zero value is a tier-1 incident signal.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(kube_node_status_condition{condition=\"Ready\",status=\"true\"} == 0) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 0 }
},
{
"id": 8, "type": "stat", "title": "Degraded Cluster Operators (OKD)",
"description": "OKD ClusterOperators currently reporting Degraded=True. Each ClusterOperator owns a core platform component — authentication, networking, image-registry, monitoring, ingress, storage, etc. A degraded operator means its managed component is impaired or unavailable. Zero is the only acceptable steady-state value outside of an active upgrade.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{ "expr": "count(cluster_operator_conditions{condition=\"Degraded\"} == 1) or vector(0)", "refId": "A", "legendFormat": "" }],
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]},
"unit": "short", "noValue": "0"
}
},
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] }, "textMode": "auto" },
"gridPos": { "h": 4, "w": 3, "x": 21, "y": 0 }
},
{
"id": 9, "type": "row", "title": "Alert Overview", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 4 }
},
{
"id": 10, "type": "timeseries", "title": "Firing Alert Count by Severity Over Time",
"description": "Instantaneous count of firing ALERTS series grouped by severity over the selected window. A vertical rise = new alerting condition emerged. A horizontal plateau = a persistent, unresolved problem. A step-down = alert resolved or Prometheus rule evaluation stopped matching. Use the Severity Filter variable to narrow scope during triage.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "count by(severity)(ALERTS{alertstate=\"firing\",severity=~\"$severity\",namespace=~\"$namespace\"})",
"refId": "A",
"legendFormat": "{{severity}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "critical" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "warning" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["max", "lastNotNull"] }
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 5 }
},
{
"id": 11, "type": "timeseries", "title": "Alertmanager Notification Rate by Integration",
"description": "Rate of notification delivery attempts from Alertmanager per second, split by integration type (slack, pagerduty, email, webhook, etc.). Solid lines = successful deliveries; dashed red lines = failed deliveries. A drop to zero on all integrations = Alertmanager is not processing or the cluster is completely quiet. Persistent failures on one integration = check that receiver's credentials or endpoint availability.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "sum by(integration)(rate(alertmanager_notifications_total[5m]))", "refId": "A", "legendFormat": "✓ {{integration}}" },
{ "expr": "sum by(integration)(rate(alertmanager_notifications_failed_total[5m]))", "refId": "B", "legendFormat": "✗ {{integration}}" }
],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0, "decimals": 3,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
},
"overrides": [
{
"matcher": { "id": "byFrameRefID", "options": "B" },
"properties": [
{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } },
{ "id": "custom.lineStyle", "value": { "dash": [6, 4], "fill": "dash" } },
{ "id": "custom.lineWidth", "value": 1 }
]
}
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 5 }
},
{
"id": 12, "type": "bargauge", "title": "Longest-Firing Active Alerts",
"description": "Duration (now - ALERTS_FOR_STATE timestamp) for each currently firing alert, sorted descending. Alerts at the top have been firing longest and are the most likely candidates for known-but-unresolved issues, stale firing conditions, or alerts that should have a silence applied. Red bars (> 2 hours) strongly suggest a problem that has been acknowledged but not resolved.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sort_desc(time() - ALERTS_FOR_STATE{alertstate=\"firing\",severity=~\"$severity\",namespace=~\"$namespace\"})",
"refId": "A",
"legendFormat": "{{alertname}} · {{severity}} · {{namespace}}"
}],
"fieldConfig": {
"defaults": {
"unit": "s", "min": 0,
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 300 },
{ "color": "orange", "value": 1800 },
{ "color": "red", "value": 7200 }
]}
}
},
"options": {
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"] },
"displayMode": "gradient",
"showUnfilled": true,
"valueMode": "color"
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 5 }
},
{
"id": 13, "type": "row", "title": "Active Firing Alerts — Full Detail", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 13 }
},
{
"id": 14, "type": "table", "title": "All Firing Alerts",
"description": "Instant-query table of every currently firing alert visible to Prometheus, filtered by the Namespace and Severity variables above. Each row is one alert instance (unique label combination). The value column is omitted — by definition every row here is firing. Use the built-in column filter (funnel icon) to further narrow to a specific alertname, pod, or node. Columns are sparse: labels not defined in a given alert rule will show '—'.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "ALERTS{alertstate=\"firing\",severity=~\"$severity\",namespace=~\"$namespace\"}",
"refId": "A",
"instant": true,
"legendFormat": ""
}],
"transformations": [
{ "id": "labelsToFields", "options": { "mode": "columns" } },
{
"id": "organize",
"options": {
"excludeByName": {
"alertstate": true,
"__name__": true,
"Value": true,
"Time": true
},
"renameByName": {
"alertname": "Alert Name",
"severity": "Severity",
"namespace": "Namespace",
"pod": "Pod",
"node": "Node",
"container": "Container",
"job": "Job",
"service": "Service",
"reason": "Reason",
"instance": "Instance"
},
"indexByName": {
"severity": 0,
"alertname": 1,
"namespace": 2,
"pod": 3,
"node": 4,
"container": 5,
"job": 6,
"service": 7,
"reason": 8,
"instance": 9
}
}
}
],
"fieldConfig": {
"defaults": {
"custom": { "align": "left", "filterable": true },
"noValue": "—"
},
"overrides": [
{
"matcher": { "id": "byName", "options": "Severity" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "custom.width", "value": 110 },
{
"id": "mappings",
"value": [{
"type": "value",
"options": {
"critical": { "text": "CRITICAL", "color": "dark-red", "index": 0 },
"warning": { "text": "WARNING", "color": "dark-yellow", "index": 1 },
"info": { "text": "INFO", "color": "dark-blue", "index": 2 }
}
}]
}
]
},
{ "matcher": { "id": "byName", "options": "Alert Name" }, "properties": [{ "id": "custom.width", "value": 300 }] },
{ "matcher": { "id": "byName", "options": "Namespace" }, "properties": [{ "id": "custom.width", "value": 180 }] },
{ "matcher": { "id": "byName", "options": "Pod" }, "properties": [{ "id": "custom.width", "value": 200 }] },
{ "matcher": { "id": "byName", "options": "Node" }, "properties": [{ "id": "custom.width", "value": 200 }] }
]
},
"options": {
"sortBy": [{ "desc": false, "displayName": "Severity" }],
"footer": { "show": false }
},
"gridPos": { "h": 12, "w": 24, "x": 0, "y": 14 }
},
{
"id": 15, "type": "row", "title": "Kubernetes Warning Events", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 26 }
},
{
"id": 16, "type": "timeseries", "title": "Warning Event Rate by Reason",
"description": "Rate of Kubernetes Warning-type events per second grouped by reason code. BackOff = container is CrashLooping. FailedScheduling = no node satisfies pod constraints. FailedMount = volume attachment or CSI failure. Evicted = kubelet evicted a pod due to memory or disk pressure. NodeNotReady = node lost contact. A spike in a single reason narrows the incident root-cause immediately without needing to read raw event logs. Requires kube-state-metrics with --resources=events.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "topk(10, sum by(reason)(rate(kube_event_count{type=\"Warning\",namespace=~\"$namespace\"}[5m])))",
"refId": "A",
"legendFormat": "{{reason}}"
}],
"fieldConfig": {
"defaults": {
"unit": "reqps", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 27 }
},
{
"id": 17, "type": "bargauge", "title": "Warning Events — Top Namespaces (Accumulated Count)",
"description": "Total accumulated Warning event count (the count field on the Kubernetes Event object) per namespace, showing the top 15 most active. A namespace dominating this chart is generating significantly more abnormal conditions than its peers, useful for identifying noisy tenants, misconfigured deployments, or namespaces experiencing a persistent infrastructure problem. Note this is the raw Event.count field — it resets if the event object is deleted and recreated.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "topk(15, sum by(namespace)(kube_event_count{type=\"Warning\"}))",
"refId": "A",
"legendFormat": "{{namespace}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 10 },
{ "color": "orange", "value": 50 },
{ "color": "red", "value": 200 }
]}
}
},
"options": {
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"] },
"displayMode": "gradient",
"showUnfilled": true
},
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 27 }
},
{
"id": 18, "type": "timeseries", "title": "Warning Events — Accumulated Count by Reason Over Time",
"description": "Raw accumulated event count gauge over time, split by reason. Unlike the rate panel this shows total volume and slope simultaneously. A line that climbs steeply = events are occurring frequently right now. A line that plateaus = the condition causing that reason has stopped. A line that drops to zero = the event object was deleted and recreated or the condition fully resolved.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "topk(10, sum by(reason)(kube_event_count{type=\"Warning\",namespace=~\"$namespace\"}))",
"refId": "A",
"legendFormat": "{{reason}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 8, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull", "max"] }
},
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 27 }
},
{
"id": 19, "type": "row", "title": "Pod Problems", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 35 }
},
{
"id": 20, "type": "timeseries", "title": "CrashLoopBackOff Pods by Namespace",
"description": "Count of container instances in CrashLoopBackOff waiting state over time, broken down by namespace. A sudden rise in one namespace = a workload deployment is failing. A persistent baseline across many namespaces = a shared dependency (Secret, ConfigMap, network policy, or an upstream service) has become unavailable. Unlike restart rate, this panel shows the steady-state count of pods currently stuck — not flapping.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "sum by(namespace)(kube_pod_container_status_waiting_reason{reason=\"CrashLoopBackOff\",namespace=~\"$namespace\"} == 1)",
"refId": "A",
"legendFormat": "{{namespace}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2, "fillOpacity": 15, "showPoints": "never", "spanNulls": false,
"thresholdsStyle": { "mode": "line" }
},
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]}
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 0, "y": 36 }
},
{
"id": 21, "type": "timeseries", "title": "Container Restart Rate by Namespace",
"description": "Rate of container restarts per second across all reasons (OOMKill, liveness probe failure, process exit) grouped by namespace. A namespace with a rising restart rate that has not yet entered CrashLoopBackOff is in the early failure window before the exponential back-off penalty kicks in. Cross-reference with the OOMKilled stat tile and the last-terminated-reason to separate crash types.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "topk(10, sum by(namespace)(rate(kube_pod_container_status_restarts_total{namespace=~\"$namespace\"}[5m])))",
"refId": "A",
"legendFormat": "{{namespace}}"
}],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0, "decimals": 4,
"color": { "mode": "palette-classic" },
"custom": { "lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false }
}
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["mean", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 8, "y": 36 }
},
{
"id": 22, "type": "timeseries", "title": "Pods by Problem Phase (Failed / Pending / Unknown)",
"description": "Count of pods in Failed, Pending, or Unknown phase over time. Failed = container terminated with a non-zero exit code or was evicted and not rescheduled. Pending for more than a few minutes = scheduler unable to bind the pod (check FailedScheduling events, node capacity, and taint/toleration mismatches). Unknown = kubelet is not reporting to the apiserver, typically indicating a node network partition or kubelet crash.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{ "expr": "sum by(phase)(kube_pod_status_phase{phase=~\"Failed|Unknown\",namespace=~\"$namespace\"} == 1)", "refId": "A", "legendFormat": "{{phase}}" },
{ "expr": "sum(kube_pod_status_phase{phase=\"Pending\",namespace=~\"$namespace\"} == 1)", "refId": "B", "legendFormat": "Pending" }
],
"fieldConfig": {
"defaults": {
"unit": "short", "min": 0,
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2, "fillOpacity": 10, "showPoints": "never", "spanNulls": false,
"thresholdsStyle": { "mode": "line" }
},
"thresholds": { "mode": "absolute", "steps": [
{ "color": "green", "value": null },
{ "color": "red", "value": 1 }
]}
},
"overrides": [
{ "matcher": { "id": "byName", "options": "Failed" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Pending" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "Unknown" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }
]
},
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": { "displayMode": "list", "placement": "bottom", "calcs": ["lastNotNull", "max"] }
},
"gridPos": { "h": 7, "w": 8, "x": 16, "y": 36 }
},
{
"id": 23, "type": "row", "title": "Node & Cluster Operator Conditions", "collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 43 }
},
{
"id": 24, "type": "table", "title": "Node Condition Status Matrix",
"description": "Instant snapshot of every active node condition across all nodes. Each row is one (node, condition, status) triple where value=1, meaning that combination is currently true. Ready=true is the normal healthy state; MemoryPressure=true, DiskPressure=true, PIDPressure=true, and NetworkUnavailable=true all indicate problem states that will affect pod scheduling on that node. Use the column filter to show only conditions where status=\"true\" and condition != \"Ready\" to isolate problems quickly.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [{
"expr": "kube_node_status_condition == 1",
"refId": "A",
"instant": true,
"legendFormat": ""
}],
"transformations": [
{ "id": "labelsToFields", "options": { "mode": "columns" } },
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": true,
"__name__": true,
"endpoint": true,
"job": true,
"service": true,
"instance": true
},
"renameByName": {
"node": "Node",
"condition": "Condition",
"status": "Status"
},
"indexByName": { "node": 0, "condition": 1, "status": 2 }
}
}
],
"fieldConfig": {
"defaults": {
"custom": { "align": "left", "filterable": true },
"noValue": "—"
},
"overrides": [
{
"matcher": { "id": "byName", "options": "Status" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "custom.width", "value": 90 },
{
"id": "mappings",
"value": [{
"type": "value",
"options": {
"true": { "text": "true", "color": "green", "index": 0 },
"false": { "text": "false", "color": "dark-red", "index": 1 },
"unknown": { "text": "unknown", "color": "dark-orange", "index": 2 }
}
}]
}
]
},
{
"matcher": { "id": "byName", "options": "Condition" },
"properties": [
{ "id": "custom.width", "value": 190 },
{ "id": "custom.displayMode", "value": "color-text" },
{
"id": "mappings",
"value": [{
"type": "value",
"options": {
"Ready": { "color": "green", "index": 0 },
"MemoryPressure": { "color": "red", "index": 1 },
"DiskPressure": { "color": "red", "index": 2 },
"PIDPressure": { "color": "red", "index": 3 },
"NetworkUnavailable": { "color": "red", "index": 4 }
}
}]
}
]
},
{ "matcher": { "id": "byName", "options": "Node" }, "properties": [{ "id": "custom.width", "value": 230 }] }
]
},
"options": {
"sortBy": [{ "desc": false, "displayName": "Node" }],
"footer": { "show": false }
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 44 }
},
{
"id": 25, "type": "table", "title": "Cluster Operator Conditions — Degraded & Progressing (OKD)",
"description": "Shows only ClusterOperator conditions that indicate a problem state: Degraded=True (operator has failed to achieve its desired state) or Progressing=True (operator is actively reconciling — normal during upgrades but alarming in steady state). Operators not appearing in this table are healthy. The reason column gives the operator's own explanation for the condition, which maps directly to the relevant operator log stream and OpenShift runbook.",
"datasource": { "type": "prometheus", "uid": "Prometheus-Cluster" },
"targets": [
{
"expr": "cluster_operator_conditions{condition=\"Degraded\"} == 1",
"refId": "A",
"instant": true,
"legendFormat": ""
},
{
"expr": "cluster_operator_conditions{condition=\"Progressing\"} == 1",
"refId": "B",
"instant": true,
"legendFormat": ""
}
],
"transformations": [
{ "id": "labelsToFields", "options": { "mode": "columns" } },
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": true,
"__name__": true,
"endpoint": true,
"job": true,
"service": true,
"instance": true,
"namespace": true
},
"renameByName": {
"name": "Operator",
"condition": "Condition",
"reason": "Reason"
},
"indexByName": { "name": 0, "condition": 1, "reason": 2 }
}
}
],
"fieldConfig": {
"defaults": {
"custom": { "align": "left", "filterable": true },
"noValue": "—"
},
"overrides": [
{
"matcher": { "id": "byName", "options": "Condition" },
"properties": [
{ "id": "custom.displayMode", "value": "color-background" },
{ "id": "custom.width", "value": 140 },
{
"id": "mappings",
"value": [{
"type": "value",
"options": {
"Degraded": { "text": "Degraded", "color": "dark-red", "index": 0 },
"Progressing": { "text": "Progressing", "color": "dark-yellow", "index": 1 }
}
}]
}
]
},
{ "matcher": { "id": "byName", "options": "Operator" }, "properties": [{ "id": "custom.width", "value": 240 }] },
{ "matcher": { "id": "byName", "options": "Reason" }, "properties": [{ "id": "custom.width", "value": 220 }] }
]
},
"options": {
"sortBy": [{ "desc": false, "displayName": "Condition" }],
"footer": { "show": false }
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 44 }
}
]
}

Some files were not shown because too many files have changed in this diff Show More