Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -864,8 +864,15 @@ Objects a removed or scaled-to-zero operator left behind (webhooks that
call its gone Service, its expired certificates, objects stuck on its
finalizers) are one low issue rooted at that operator, "Leftovers of a
removed or stopped controller (N objects)", not one issue per object; a
leftover that blocks requests keeps its own issue and tier. Problems that
were already old when kwatch first saw them follow `oldProblemDays`.
leftover that blocks requests keeps its own issue and tier. Sharing a
namespace is not enough to join: an object joins when it is named after
the operator or its Service, carries the same
`app.kubernetes.io/instance`, `app.kubernetes.io/part-of` or Helm release
(`meta.helm.sh/release-name`), or is the Secret cert-manager injects the
webhook's CA from. Two operators in one namespace are two issues. An
object stuck on the same finalizer as one already in an issue joins that
issue at once. Problems that were already old when kwatch first saw
them follow `oldProblemDays`.

Every message starts with exactly one status emoji and no other: 🔴 page,
🟠 notify, 🟡 low, ✅ resolved. Red is reserved for incidents that page.
Expand Down
2 changes: 1 addition & 1 deletion docs/kubernetes-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Health is taken from the severity each detector assigns: warning is degraded, cr
| Budget.SelectsNothing | degraded | `PdbSelectsNothing` | `budget` |
| Budget.SyncFailed | degraded | `PdbSyncFailed` | `condition_rules` |
| CPUHigh | degraded | `ContainerCPUUsageHigh` | `resources` |
| CPUThrottled | varies | `ContainerCPUThrottled` | `resources` |
| CPUThrottled | degraded | `ContainerCPUThrottled` | `cpu_capped`, `resources` |
| CRD.NotEstablished | degraded | `CustomResourceDefinitionNotEstablished` | `condition_rules` |
| CSRFailed | varies | `CertificateSigningRequestFailure` | `custom` |
| Cert.Expired | degraded | `TLSCertExpired` | `config` |
Expand Down
3 changes: 2 additions & 1 deletion internal/alert/webex/webex.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ func NewWebex(
return nil
}

klog.InfoS("initializing webex", "roomId", roomID, "toPersonEmail", toPersonEmail)
klog.InfoS("initializing webex",
"roomId", roomID, "toPersonEmail", toPersonEmail)

return &Webex{
sender: transport.NewSender(dependencies),
Expand Down
2 changes: 1 addition & 1 deletion internal/dashboard/detail_affected.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const (
var notKubectl = map[inventory.Kind]bool{
kube.KindImage: true, kube.KindRegistry: true, kube.KindZone: true,
kube.KindNodePool: true, kube.KindExternalEndpoint: true,
kube.KindContainer: true,
kube.KindContainer: true, rootcause.KindScheduling: true,
}

// affectedOf lists the objects p is about: its root, the other roots
Expand Down
33 changes: 33 additions & 0 deletions internal/dashboard/leftovers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package dashboard

import (
"fmt"

"github.com/abahmed/kwatch/internal/detection/reasons"
"github.com/abahmed/kwatch/internal/incident"
"github.com/abahmed/kwatch/internal/inventory"
"github.com/abahmed/kwatch/internal/redact"
Expand Down Expand Up @@ -62,3 +65,33 @@ func joinNew(have, more, sep string) string {
}
return have + sep + more
}

// leftoverWhat is the summary of an issue that groups what a removed or
// stopped controller left behind: "Leftovers of Deployment op in ops:
// 4 objects it left behind while it has no ready pods." The root is
// not stuck deleting nor expired: its members are, each in the list.
// ok is false for any other issue, and for a lone leftover.
func leftoverWhat(p incident.Incident) (string, bool) {
n := p.LeftoverObjects()
if n < 2 || p.Root.Name == "" {
return "", false
}
now, was := "is gone or has nothing ready",
"was gone or had nothing ready"
for _, m := range p.Members {
switch m.Reason {
case reasons.WebhookBackendNotFound:
now, was = "is gone", "was gone"
case reasons.WebhookNoEndpoints:
if now != "is gone" {
now, was = "has no ready pods", "had no ready pods"
}
}
}
state := now
if p.State == incident.Resolved {
state = was
}
return fmt.Sprintf("Leftovers of %s: %d objects it left behind "+
"while it %s.", rootWords(p.Root), n, state), true
}
18 changes: 18 additions & 0 deletions internal/dashboard/leftovers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,21 @@ func TestOverviewSaysHowManyDaysMakeAProblemOld(t *testing.T) {
w = getPart(t, s, "/api/overview", "oldProblems")
assert.NotContains(t, w.Body.String(), `"days"`)
}

// The summary of a leftover issue says what the root left behind, not
// one member's sentence as if it were the root's.
func TestLeftoverIssueSummaryLeadsWithTheLeftovers(t *testing.T) {
p := leftoverIssue()
p.Mode = detection.ModeStuckDeleting
d := toDetail(p)

got := whatOf(&d, p)

assert.Equal(t, "Leftovers of Service policy-svc in policy-system: "+
"2 objects it left behind while it is gone.", got)
assert.NotContains(t, got, "stuck deleting")
assert.NotContains(t, got, "expired")

p.State = incident.Resolved
assert.Contains(t, whatOf(&d, p), "while it was gone.")
}
111 changes: 111 additions & 0 deletions internal/dashboard/summary_selfcause.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package dashboard

import (
"strings"
"unicode"
"unicode/utf8"

"github.com/abahmed/kwatch/internal/detection"
"github.com/abahmed/kwatch/internal/incident"
"github.com/abahmed/kwatch/internal/inventory"
"github.com/abahmed/kwatch/internal/rootcause"
)

// configFamily is the mode family of a workload broken by its own
// settings ("Config.ProbePortMismatch").
const configFamily detection.Mode = "Config"

// noOutsideCause opens the cause sentence of a workload that fails on
// its own.
const noOutsideCause = "kwatch hasn't found an outside cause"

// failsOnItsOwn reports a cause that is the incident's own workload:
// every failing object is the root itself or one of its pods, so
// naming the root as their "common cause" says nothing.
func failsOnItsOwn(p incident.Incident, root inventory.EntityID) bool {
if root.Name == "" || root.Kind == rootcause.KindScheduling {
return false
}
owned := 0
for _, f := range p.Members {
if f.Advisory {
continue
}
if !ownedName(root, f.Entity) {
return false
}
owned++
}
return owned > 0 || root == p.Root
}

// ownedName reports the root itself, or an object of its own named
// after it: its pods and containers, or a Service of the same name.
func ownedName(root, e inventory.EntityID) bool {
if e == root {
return true
}
return e.Namespace == root.Namespace && root.Name != "" &&
(e.Name == root.Name || strings.HasPrefix(e.Name, root.Name+"-") ||
strings.HasPrefix(e.Name, root.Name+"/"))
}

// selfCause says why a workload fails on its own: the setting the
// title blames, else the error its logs report, else how its
// container last stopped, else that nothing outside it was found.
func selfCause(d *IncidentDetail, p incident.Incident) string {
if own := configCause(d, p); own != "" {
return own
}
if logs := logProof(d); len(logs) > 0 {
line := strings.TrimRight(logs[0].Detail, ". ")
line, _ = shortText(line, maxProofDetail)
return noOutsideCause + "; its own logs say: “" + line + "”."
}
if exits := exitRows(d); len(exits) > 0 {
proof := exitProof(d)[0]
return noOutsideCause + "; its container " + exits[0].Container +
" last stopped: " + proof.Detail + "."
}
return noOutsideCause + ": the failure is in the " +
strings.ToLower(kindDisplay(p.Root)) + " " + p.Root.Name +
" itself."
}

// configCause is the setting a self-configuration cause blames, as the
// title states it after "because": "Its probe checks a port the
// container does not listen on." "" for any other cause.
func configCause(d *IncidentDetail, p incident.Incident) string {
if p.Cause.Mode.Family() != configFamily {
return ""
}
title := d.FullTitle
if title == "" {
title = d.Title
}
_, why, found := strings.Cut(title, " because ")
if !found || strings.HasSuffix(why, "…") {
return ""
}
return sentence(upperFirst(why))
}

// upperFirst is s with a capital first letter.
func upperFirst(s string) string {
r, n := utf8.DecodeRuneInString(s)
if n == 0 {
return s
}
return string(unicode.ToUpper(r)) + s[n:]
}

// schedulingCause words a virtual scheduling root: "The scheduler
// can't place the pod: no node matches its node affinity."
func schedulingCause(p incident.Incident, root inventory.EntityID) string {
what := "the pod"
if len(p.Members) > 1 {
what = "the pods"
}
return "The scheduler can't place " + what + ": " +
rootcause.SchedulingWords(root.Name) + "."
}
7 changes: 5 additions & 2 deletions internal/dashboard/summary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/abahmed/kwatch/internal/detection"
"github.com/abahmed/kwatch/internal/incident"
"github.com/abahmed/kwatch/internal/inventory"
"github.com/abahmed/kwatch/internal/rootcause"
)

Expand Down Expand Up @@ -120,13 +121,15 @@ func TestSummaryWhyWordsTheConfidence(t *testing.T) {
}
for _, c := range cases {
p.Cause = &rootcause.CauseRecord{Summary: "the image is wrong",
Score: c.score}
Score: c.score, Root: inventory.EntityID{Kind: "configmap",
Namespace: "shop", Name: "settings"}}
if c.rival {
p.Cause.Rival = &rootcause.CauseRecord{}
}
d := toDetail(p)
got := whyText(&d, p)
assert.Equal(t, SummaryWhy{Text: "The deployment api is the cause.",
assert.Equal(t, SummaryWhy{Text: "The configmap settings is the " +
"cause.",
Confidence: c.want, CauseKnown: true}, got)
}
}
Expand Down
10 changes: 9 additions & 1 deletion internal/dashboard/summary_what.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/abahmed/kwatch/internal/incident"
"github.com/abahmed/kwatch/internal/inventory"
"github.com/abahmed/kwatch/internal/redact"
"github.com/abahmed/kwatch/internal/rootcause"
)

// symptom is what is wrong in present and past tense, after the
Expand Down Expand Up @@ -65,6 +66,11 @@ var probeSymptoms = map[string]symptom{
// latest finding's own sentence takes its place ("2 of 3 Service
// backends are ready").
func whatOf(d *IncidentDetail, p incident.Incident) string {
if lead, ok := leftoverWhat(p); ok {
// No member's sentence is the root's: each says its own in
// the leftovers list.
return lead
}
what := symptomOf(d, p)
if bare(what) != "" && bare(what) != bare(d.Title) {
return what
Expand Down Expand Up @@ -145,8 +151,10 @@ func symptomOf(d *IncidentDetail, p incident.Incident) string {

// subjectOf names the root as a reader says it: "Deployment api in
// shop", "Node n1".
// A virtual scheduling root names the scheduler's reason, not an
// object: the title says it in words instead.
func subjectOf(p incident.Incident) string {
if p.Root.Name == "" {
if p.Root.Name == "" || p.Root.Kind == rootcause.KindScheduling {
return ""
}
return rootWords(p.Root)
Expand Down
28 changes: 24 additions & 4 deletions internal/dashboard/summary_why.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"time"

"github.com/abahmed/kwatch/internal/incident"
"github.com/abahmed/kwatch/internal/inventory"
"github.com/abahmed/kwatch/internal/release"
"github.com/abahmed/kwatch/internal/rootcause"
)

// causeSentence says the cause in plain words, from the cause record
Expand All @@ -23,7 +25,7 @@ func causeSentence(d *IncidentDetail, p incident.Incident) string {
if timing != "" {
return timing
}
return commonCause(p)
return commonCause(d, p)
}

// startedSentence is the release or change the incident started
Expand All @@ -34,19 +36,37 @@ func startedSentence(d *IncidentDetail, p incident.Incident) string {
return startedAfter(onset, r.At, "the release") +
releaseWhat(r.Record)
}
if c := p.Cause.Change; c != nil && !c.At.IsZero() {
if c := p.Cause.Change; c != nil && !c.At.IsZero() &&
!ownCreation(p, *c) {
return startedAfter(onset, c.At, "a change to "+
kindDisplay(c.Entity)+" "+c.Entity.Name)
}
return ""
}

// commonCause names the object that fails the others with it.
func commonCause(p incident.Incident) string {
// ownCreation reports a change that is the object's own creation: a
// Job that fails is always created just before, which explains
// nothing.
func ownCreation(p incident.Incident, c inventory.Change) bool {
return c.Created && (ownedName(p.Root, c.Entity) ||
ownedName(p.Cause.Root, c.Entity))
}

// commonCause names the object that fails the others with it. A root
// that is the incident's own workload is not named as its own cause:
// its own reason is said instead, and a scheduling constraint is
// worded, not quoted.
func commonCause(d *IncidentDetail, p incident.Incident) string {
root := p.Cause.Root
if root.Name == "" {
root = p.Root
}
switch {
case root.Kind == rootcause.KindScheduling:
return schedulingCause(p, root)
case failsOnItsOwn(p, root):
return selfCause(d, p)
}
name := strings.ToLower(kindDisplay(root)) + " " + root.Name
if n := len(p.Members); n > 1 {
return fmt.Sprintf("The %s is the common cause of %d failing "+
Expand Down
Loading