mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Stop repeating Kubernetes workload names in the demo (#2475)
The demo scenario renamed every generated deployment to one of four curated names and every pod to one of ten, cycling by index. With the default 14 deployments and 40 pods per cluster the Kubernetes overview listed checkout-api four times in one namespace, and the curated pending and crash-looping pods appeared more than once. Kubernetes itself refuses duplicate names in a namespace, so the public demo looked broken. Ten more healthy services fill out the default deployment count, a numeric suffix keeps any larger count unique, and pods past the curated story become healthy ReplicaSet-style replicas of the cluster's own deployments on ready nodes, so each curated failure appears once. Checked on a mock stack: the Kubernetes overview lists 14 distinct deployments and every pod name once, with the curated pending pod shown once. The new test covers the default, 30/60, 0/40 and 15/631 shapes, a single crash loop across the estate, healthy repeats, ready-node placement and name stability across a metric refresh.
This commit is contained in:
@@ -267,6 +267,19 @@ var demoKubernetesDeploymentProfiles = []demoKubernetesDeploymentProfile{
|
||||
{Name: "checkout-api", Namespace: "services", DesiredReplicas: 3, ReadyReplicas: 3},
|
||||
{Name: "payments-worker", Namespace: "services", DesiredReplicas: 2, ReadyReplicas: 1},
|
||||
{Name: "platform-observability", Namespace: "monitoring", DesiredReplicas: 2, ReadyReplicas: 2},
|
||||
// Healthy services that fill out the default deployment count, so the
|
||||
// demo never shows one deployment name repeated within a namespace (the
|
||||
// Kubernetes API forbids that, and the demo looked broken with it).
|
||||
{Name: "ingress-nginx-controller", Namespace: "ingress-nginx", DesiredReplicas: 2, ReadyReplicas: 2},
|
||||
{Name: "storefront-web", Namespace: "apps", DesiredReplicas: 2, ReadyReplicas: 2},
|
||||
{Name: "account-portal", Namespace: "apps", DesiredReplicas: 2, ReadyReplicas: 2},
|
||||
{Name: "search-api", Namespace: "services", DesiredReplicas: 3, ReadyReplicas: 3},
|
||||
{Name: "inventory-api", Namespace: "services", DesiredReplicas: 2, ReadyReplicas: 2},
|
||||
{Name: "order-events-consumer", Namespace: "services", DesiredReplicas: 2, ReadyReplicas: 2},
|
||||
{Name: "notification-worker", Namespace: "services", DesiredReplicas: 1, ReadyReplicas: 1},
|
||||
{Name: "image-resizer", Namespace: "services", DesiredReplicas: 2, ReadyReplicas: 2},
|
||||
{Name: "session-cache", Namespace: "services", DesiredReplicas: 1, ReadyReplicas: 1},
|
||||
{Name: "metrics-gateway", Namespace: "monitoring", DesiredReplicas: 1, ReadyReplicas: 1},
|
||||
}
|
||||
|
||||
var demoKubernetesPodProfiles = []demoKubernetesPodProfile{
|
||||
@@ -750,6 +763,13 @@ func applyDemoKubernetesScenario(state *models.StateSnapshot, now time.Time) {
|
||||
clusterCarriesDeployCrash := profile.Scenario == demoK8sScenarioCrashLoopBackOff
|
||||
for i := range cluster.Deployments {
|
||||
deploymentProfile := demoKubernetesDeploymentProfiles[i%len(demoKubernetesDeploymentProfiles)]
|
||||
if round := i / len(demoKubernetesDeploymentProfiles); round > 0 {
|
||||
// Repeats keep the name unique and stay healthy, so a curated
|
||||
// degradation such as the under-replicated payments-worker
|
||||
// is told once rather than once per round.
|
||||
deploymentProfile.Name = fmt.Sprintf("%s-%d", deploymentProfile.Name, round+1)
|
||||
deploymentProfile.ReadyReplicas = deploymentProfile.DesiredReplicas
|
||||
}
|
||||
desired := deploymentProfile.DesiredReplicas
|
||||
ready := deploymentProfile.ReadyReplicas
|
||||
// payments-worker is under-replicated only in the cluster that
|
||||
@@ -770,11 +790,35 @@ func applyDemoKubernetesScenario(state *models.StateSnapshot, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
readyNodeIndexes := make([]int, 0, len(cluster.Nodes))
|
||||
for i := range cluster.Nodes {
|
||||
if cluster.Nodes[i].Ready {
|
||||
readyNodeIndexes = append(readyNodeIndexes, i)
|
||||
}
|
||||
}
|
||||
for i := range cluster.Pods {
|
||||
podProfile := applyDemoKubernetesPodScenario(
|
||||
demoKubernetesPodProfiles[i%len(demoKubernetesPodProfiles)],
|
||||
profile.Scenario,
|
||||
)
|
||||
var podProfile demoKubernetesPodProfile
|
||||
switch {
|
||||
case i < len(demoKubernetesPodProfiles):
|
||||
podProfile = applyDemoKubernetesPodScenario(demoKubernetesPodProfiles[i], profile.Scenario)
|
||||
case len(cluster.Deployments) == 0:
|
||||
// No deployments to own replicas: repeat a curated pod under
|
||||
// a unique name, healthy, so its story is not told twice.
|
||||
podProfile = demoKubernetesHealthyRepeatPodProfile(
|
||||
demoKubernetesPodProfiles[i%len(demoKubernetesPodProfiles)],
|
||||
i/len(demoKubernetesPodProfiles)+1,
|
||||
)
|
||||
default:
|
||||
// Past the curated story, pods are healthy replicas of the
|
||||
// cluster's deployments with unique names, so the curated
|
||||
// failures appear once and no pod name repeats.
|
||||
podProfile = demoKubernetesReplicaPodProfile(
|
||||
cluster.ID,
|
||||
cluster.Deployments[(i-len(demoKubernetesPodProfiles))%len(cluster.Deployments)],
|
||||
i,
|
||||
readyNodeIndexes,
|
||||
)
|
||||
}
|
||||
pod := &cluster.Pods[i]
|
||||
pod.Name = podProfile.Name
|
||||
pod.Namespace = podProfile.Namespace
|
||||
@@ -825,6 +869,49 @@ func applyDemoKubernetesScenario(state *models.StateSnapshot, now time.Time) {
|
||||
syncMockKubernetesNodeHosts(state)
|
||||
}
|
||||
|
||||
// demoKubernetesReplicaPodProfile describes one healthy pod of a demo
|
||||
// deployment, named the way a ReplicaSet names its pods. The pod index feeds
|
||||
// the suffix so every replica stays unique within the cluster.
|
||||
func demoKubernetesReplicaPodProfile(
|
||||
clusterID string,
|
||||
deployment models.KubernetesDeployment,
|
||||
podIndex int,
|
||||
readyNodeIndexes []int,
|
||||
) demoKubernetesPodProfile {
|
||||
nodeIndex := -1
|
||||
if len(readyNodeIndexes) > 0 {
|
||||
nodeIndex = readyNodeIndexes[podIndex%len(readyNodeIndexes)]
|
||||
}
|
||||
replicaSetHash := mockStableHexString(10, clusterID, deployment.Namespace, deployment.Name, "replicaset")
|
||||
// A hash alone can collide; ending the suffix with the pod index keeps
|
||||
// every replica unique while the fixed-width hash keeps it looking real.
|
||||
podSuffix := mockStableHexString(3, clusterID, deployment.Namespace, deployment.Name, "pod") +
|
||||
strconv.FormatInt(int64(podIndex), 36)
|
||||
return demoKubernetesPodProfile{
|
||||
Name: fmt.Sprintf("%s-%s-%s", deployment.Name, replicaSetHash, podSuffix),
|
||||
Namespace: deployment.Namespace,
|
||||
NodeIndex: nodeIndex,
|
||||
OwnerKind: "Deployment",
|
||||
OwnerName: deployment.Name,
|
||||
Phase: "Running",
|
||||
Container: deployment.Name,
|
||||
Image: fmt.Sprintf("ghcr.io/pulse-demo/%s:2026.04", deployment.Name),
|
||||
ContainerOK: true,
|
||||
}
|
||||
}
|
||||
|
||||
// demoKubernetesHealthyRepeatPodProfile reuses a curated pod's shape under a
|
||||
// unique name with its failure story scrubbed.
|
||||
func demoKubernetesHealthyRepeatPodProfile(profile demoKubernetesPodProfile, round int) demoKubernetesPodProfile {
|
||||
profile.Name = fmt.Sprintf("%s-%d", profile.Name, round)
|
||||
profile.Phase = "Running"
|
||||
profile.Reason = ""
|
||||
profile.Message = ""
|
||||
profile.Restarts = 0
|
||||
profile.ContainerOK = true
|
||||
return profile
|
||||
}
|
||||
|
||||
// applyDemoKubernetesPodScenario rewrites a global pod profile so the
|
||||
// cluster's chosen scenario is the only place its degraded state appears.
|
||||
// Production EU stays healthy across the board; Staging EU keeps the
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1011,3 +1013,103 @@ func TestDemoDockerContainerNamesAreUniquePerHost(t *testing.T) {
|
||||
t.Fatal("expected the first round of curated names to stay unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDemoKubernetesWorkloadNamesAreUniquePerNamespace(t *testing.T) {
|
||||
curatedPods := map[string]struct{}{}
|
||||
for _, profile := range demoKubernetesPodProfiles {
|
||||
curatedPods[profile.Name] = struct{}{}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
deployments int
|
||||
pods int
|
||||
}{
|
||||
{name: "default", deployments: DefaultConfig.K8sDeploymentsPerCluster, pods: DefaultConfig.K8sPodsPerCluster},
|
||||
{name: "beyond curated profiles", deployments: 30, pods: 60},
|
||||
{name: "no deployments", deployments: 0, pods: 40},
|
||||
{name: "many replicas", deployments: 15, pods: 631},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := DefaultConfig
|
||||
cfg.RandomMetrics = false
|
||||
cfg.K8sDeploymentsPerCluster = tc.deployments
|
||||
cfg.K8sPodsPerCluster = tc.pods
|
||||
|
||||
start := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC)
|
||||
graph := buildFixtureGraph(cfg, start)
|
||||
before := kubernetesWorkloadKeys(graph)
|
||||
|
||||
// Kubernetes refuses two deployments or two pods with one name in
|
||||
// a namespace. The demo cycled four deployment names and ten pod
|
||||
// names, so the overview showed "checkout-api" four times and the
|
||||
// curated crash-looping pod more than once.
|
||||
totalCrashLooping := 0
|
||||
for _, cluster := range graph.State.KubernetesClusters {
|
||||
readyNodes := map[string]bool{}
|
||||
for _, node := range cluster.Nodes {
|
||||
readyNodes[node.Name] = node.Ready
|
||||
}
|
||||
deployments := map[string]struct{}{}
|
||||
for _, deployment := range cluster.Deployments {
|
||||
key := deployment.Namespace + "/" + deployment.Name
|
||||
if _, dup := deployments[key]; dup {
|
||||
t.Fatalf("cluster %s names two deployments %q", cluster.DisplayName, key)
|
||||
}
|
||||
deployments[key] = struct{}{}
|
||||
if deployment.ReadyReplicas < deployment.DesiredReplicas && deployment.Name != "payments-worker" {
|
||||
t.Fatalf("cluster %s repeats a degraded deployment as %q", cluster.DisplayName, key)
|
||||
}
|
||||
}
|
||||
pods := map[string]struct{}{}
|
||||
crashLooping := 0
|
||||
for _, pod := range cluster.Pods {
|
||||
key := pod.Namespace + "/" + pod.Name
|
||||
if _, dup := pods[key]; dup {
|
||||
t.Fatalf("cluster %s names two pods %q", cluster.DisplayName, key)
|
||||
}
|
||||
pods[key] = struct{}{}
|
||||
if _, curated := curatedPods[pod.Name]; !curated && pod.NodeName != "" && !readyNodes[pod.NodeName] {
|
||||
t.Fatalf("cluster %s placed replica %q on not-ready node %q", cluster.DisplayName, key, pod.NodeName)
|
||||
}
|
||||
if tc.deployments >= len(demoKubernetesDeploymentProfiles) && pod.OwnerKind == "Deployment" {
|
||||
if _, ok := deployments[pod.Namespace+"/"+pod.OwnerName]; !ok {
|
||||
t.Fatalf("cluster %s pod %q is owned by missing deployment %q", cluster.DisplayName, key, pod.OwnerName)
|
||||
}
|
||||
}
|
||||
for _, container := range pod.Containers {
|
||||
if container.Reason == "CrashLoopBackOff" {
|
||||
crashLooping++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if crashLooping > 1 {
|
||||
t.Fatalf("cluster %s repeats the curated crash loop on %d pods", cluster.DisplayName, crashLooping)
|
||||
}
|
||||
totalCrashLooping += crashLooping
|
||||
}
|
||||
if totalCrashLooping != 1 {
|
||||
t.Fatalf("expected the curated crash loop exactly once across the estate, got %d", totalCrashLooping)
|
||||
}
|
||||
|
||||
graph.UpdateMetrics(cfg, start.Add(45*time.Minute))
|
||||
if after := kubernetesWorkloadKeys(graph); !reflect.DeepEqual(before, after) {
|
||||
t.Fatal("expected Kubernetes workload names to survive a metric refresh unchanged")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func kubernetesWorkloadKeys(graph FixtureGraph) []string {
|
||||
keys := []string{}
|
||||
for _, cluster := range graph.State.KubernetesClusters {
|
||||
for _, deployment := range cluster.Deployments {
|
||||
keys = append(keys, cluster.ID+"/deploy/"+deployment.Namespace+"/"+deployment.Name)
|
||||
}
|
||||
for _, pod := range cluster.Pods {
|
||||
keys = append(keys, cluster.ID+"/pod/"+pod.Namespace+"/"+pod.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user