From b8554bdd78f9814e4c07d551411edf74d7a65796 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:54:50 +0100 Subject: [PATCH] Stop repeating Kubernetes workload names in the demo 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. Contract-Neutral: Mock fixture naming only: demo Kubernetes deployments and pods get unique names; no runtime contract delta. --- internal/mock/demo_scenarios.go | 95 +++++++++++++++++++++++-- internal/mock/demo_scenarios_test.go | 102 +++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 4 deletions(-) diff --git a/internal/mock/demo_scenarios.go b/internal/mock/demo_scenarios.go index 6211b82793..5918053cfc 100644 --- a/internal/mock/demo_scenarios.go +++ b/internal/mock/demo_scenarios.go @@ -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 diff --git a/internal/mock/demo_scenarios_test.go b/internal/mock/demo_scenarios_test.go index 4b46d58649..410aec2feb 100644 --- a/internal/mock/demo_scenarios_test.go +++ b/internal/mock/demo_scenarios_test.go @@ -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 +}