laya: incident decision engine over alert + pod state
Adds internal/laya, a client and safety-gated decision engine for the Laya decision API (https://laya.k6n.net). The API's OpenAPI document leaves every response schema empty, so the wire format was pinned by probing the live service: questions are typed (choice/score/noul) and carry "criteria" as an object for a choice and an array for a score. An alert is rendered into a compact factual state and asked four typed questions: which action (restart/ignore/notify/escalate), severity and how urgent. The state always includes the pods' last events - restart count, readiness, last termination reason and exit code, log tail, and any overlapping rollout - because a 503 burst from a pod being replaced is otherwise indistinguishable from an outage. The model proposes, the policy disposes. Every gate records why it changed an answer: an executable-action allowlist, a probability and lead-over-runner-up threshold, no restart while pods are crash-looping (the kubelet is already doing it) or while a rollout is replacing every pod (the 503s are the rollout's own), never ignoring a new error or a high-priority spike without a human's mute, an hourly restart budget per deployment, and a plain notification whenever the model is unreachable. The margin, not the raw probability, is the useful signal: label probabilities are diffuse and a correct answer rarely exceeds 0.5. topology.PodLog now carries the pod's lifecycle facts alongside the log tail. PodsNotReady/CrashLooping helpers build on them. Verified end to end against the live API: a crash loop is withheld as "already crash-looping", a rollout's 503 burst is held back at a 2% margin, and crawler 404s are ignored at 13%. LAYA_URL=... go test ./internal/laya -run TestLiveModel replays it.
This commit is contained in:
1 parent
7a075fbe9a
commit
8d71ef06c6
5 files changed
+1354
-2
No files matched your search
@@ -16,6 +16,38 @@ type PodLog struct {
|
||||
Lines []string // most recent log lines, oldest first
|
||||
Found bool // a pod exists and was queried (Lines may still be empty: a quiet pod)
|
||||
Err string // set when the lookup itself failed; Found is false
|
||||
|
||||
// The pod's own lifecycle facts, which explain errors the log lines alone
|
||||
// cannot: a 503 burst while a replica is being replaced, a crash loop the
|
||||
// kubelet is already retrying, or a pod that runs but never becomes ready.
|
||||
Ready bool // container passing its readiness probe
|
||||
Phase string // Pending | Running | Succeeded | Failed | Unknown
|
||||
Restarts int32 // restarts of the selected pod's containers
|
||||
PodRestarts int32 // restarts summed over every pod of the Deployment
|
||||
Reason string // CrashLoopBackOff, OOMKilled, ImagePullBackOff, ... ("" while healthy)
|
||||
ExitCode *int32 // exit code of the last container termination
|
||||
Message string // message of the last container termination
|
||||
LastTerminatedAt time.Time // when the last container terminated
|
||||
}
|
||||
|
||||
// rollingWindow is how recently a container must have died for the resulting
|
||||
// connection errors to be attributed to its replacement still coming up.
|
||||
const rollingWindow = 5 * time.Minute
|
||||
|
||||
// CrashLooping reports whether the pod is stuck restarting instead of serving.
|
||||
// Restarting the Deployment then only repeats what the kubelet is already
|
||||
// doing, so the answer is a new release, not a restart.
|
||||
func (p PodLog) CrashLooping() bool {
|
||||
if p.Reason == "CrashLoopBackOff" {
|
||||
return true
|
||||
}
|
||||
return p.Restarts > 0 && !p.LastTerminatedAt.IsZero()
|
||||
}
|
||||
|
||||
// Restarting reports whether a pod of this Deployment is right now being
|
||||
// replaced, which is what makes a short 503 burst expected rather than new.
|
||||
func (p PodLog) Restarting() bool {
|
||||
return !p.LastTerminatedAt.IsZero() && time.Since(p.LastTerminatedAt) < rollingWindow
|
||||
}
|
||||
|
||||
// podLogTimeout bounds one lookup so a slow or unreachable API server never
|
||||
@@ -64,6 +96,7 @@ func (r *Registry) PodLogs(ctx context.Context, namespace, deployment string, ta
|
||||
return live[i].CreationTimestamp.After(live[j].CreationTimestamp.Time)
|
||||
})
|
||||
pod := live[0]
|
||||
state := podState(pod, live)
|
||||
container := ""
|
||||
if len(pod.Spec.Containers) > 0 {
|
||||
container = pod.Spec.Containers[0].Name
|
||||
@@ -71,7 +104,7 @@ func (r *Registry) PodLogs(ctx context.Context, namespace, deployment string, ta
|
||||
tailLines := int64(tail)
|
||||
stream, err := r.client.CoreV1().Pods(namespace).GetLogs(pod.Name, &corev1.PodLogOptions{Container: container, TailLines: &tailLines}).Stream(ctx)
|
||||
if err != nil {
|
||||
return PodLog{Pod: pod.Name, Found: true, Err: err.Error()}
|
||||
return withState(state, PodLog{Pod: pod.Name, Found: true, Err: err.Error()})
|
||||
}
|
||||
defer stream.Close()
|
||||
var lines []string
|
||||
@@ -83,5 +116,53 @@ func (r *Registry) PodLogs(ctx context.Context, namespace, deployment string, ta
|
||||
if len(lines) > tail {
|
||||
lines = lines[len(lines)-tail:]
|
||||
}
|
||||
return PodLog{Pod: pod.Name, Lines: lines, Found: true}
|
||||
return withState(state, PodLog{Pod: pod.Name, Lines: lines, Found: true})
|
||||
}
|
||||
|
||||
// withState keeps the query result but carries the lifecycle facts along.
|
||||
func withState(state, result PodLog) PodLog {
|
||||
result.Ready, result.Phase, result.Restarts, result.PodRestarts = state.Ready, state.Phase, state.Restarts, state.PodRestarts
|
||||
result.Reason, result.ExitCode, result.Message, result.LastTerminatedAt = state.Reason, state.ExitCode, state.Message, state.LastTerminatedAt
|
||||
return result
|
||||
}
|
||||
|
||||
// podState reads the lifecycle facts off the selected pod and totals the
|
||||
// restarts of its siblings, so one unhealthy replica is not mistaken for a
|
||||
// healthy deployment.
|
||||
func podState(selected corev1.Pod, all []corev1.Pod) PodLog {
|
||||
state := PodLog{Found: true, Ready: len(selected.Status.ContainerStatuses) > 0, Phase: string(selected.Status.Phase)}
|
||||
// The most recent termination across the pod's containers is the
|
||||
// interesting one: a pod being replaced is explained by its last death.
|
||||
keepTermination := func(term *corev1.ContainerStateTerminated) {
|
||||
if term == nil || !term.FinishedAt.Time.After(state.LastTerminatedAt) {
|
||||
return
|
||||
}
|
||||
state.LastTerminatedAt = term.FinishedAt.Time
|
||||
state.Message = term.Message
|
||||
code := term.ExitCode
|
||||
state.ExitCode = &code
|
||||
switch {
|
||||
case term.Reason != "":
|
||||
state.Reason = term.Reason
|
||||
case term.Message != "":
|
||||
state.Reason = "Error"
|
||||
}
|
||||
}
|
||||
for i := range selected.Status.ContainerStatuses {
|
||||
cs := &selected.Status.ContainerStatuses[i]
|
||||
state.Restarts += cs.RestartCount
|
||||
if !cs.Ready {
|
||||
state.Ready = false
|
||||
}
|
||||
keepTermination(cs.LastTerminationState.Terminated)
|
||||
}
|
||||
if selected.Status.Reason != "" {
|
||||
state.Reason = selected.Status.Reason
|
||||
}
|
||||
for _, p := range all {
|
||||
for _, cs := range p.Status.ContainerStatuses {
|
||||
state.PodRestarts += cs.RestartCount
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
Reference in new issue
Block a user