Skip to content

Commit a8b7288

Browse files
committed
Make batch workloads first-class
1 parent 1f47816 commit a8b7288

60 files changed

Lines changed: 5054 additions & 284 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

NOTES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Autodev Notes
2+
3+
## 2026-07-10 - batch workload rebase over #1106
4+
5+
- Assumption: PR #1106's redesigned application/workload scope model is the base design; batch support should extend the new Overview/Topology/History and workload-scope surfaces instead of restoring the older rail/top-band layout.
6+
- Decision: Keep operational Issues in the app Overview by embedding the existing Issues row renderer scoped to app workloads, not by creating a separate incident-summary component.
7+
- Decision: Preserve pod log health metadata from main and add Argo workflow step metadata as separate fields (`stepID`, `stepName`, `stepPhase`) so workflow context does not overwrite Kubernetes pod phase.
8+
- Decision: Add only additive shared UI props for batch Overview overrides and app Overview issue slots; do not remove or rename existing `@skyhook-io/k8s-ui` exports.

docs/mcp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ Add to `~/.gemini/settings.json`:
194194
| `get_events` | Recent Kubernetes Warning events, deduplicated and sorted by recency. Filter by resource kind/name to scope. | `namespace` (optional), `limit` (optional, default 20, max 100), `kind` (optional), `name` (optional) |
195195
| `get_changes` | Recent meaningful changes from the Kubernetes cluster timeline plus native Helm release deployment/operation history (`source: helm`). Use to investigate what changed before an incident, including failed upgrades, rollbacks, and current Helm revisions. If the response includes `sourcesErrored`, treat it as partial data for those sources. Use `get_helm_release include=history,operations` for the full Helm revision trail. | `namespace` (optional), `kind` (optional), `name` (optional), `since` (optional, e.g. `1h`, `30m`; default `1h`), `limit` (optional, default 20, max 50) |
196196
| `get_pod_logs` | Filtered pod logs prioritizing errors/warnings, with secret redaction. Set `grep` for server-side filtering. | `namespace` (required), `name` (required), `container` (optional), `tail_lines` (optional, default 200), `grep` (optional) |
197-
| `get_workload_logs` | Aggregated, AI-filtered logs from all pods of a workload (Deployment, StatefulSet, DaemonSet) | `kind` (required), `namespace` (required), `name` (required), `container` (optional), `tail_lines` (optional, default 100 per pod), `grep` (optional) |
197+
| `get_workload_logs` | Aggregated, AI-filtered logs from all pods of a workload (Deployment, StatefulSet, DaemonSet, Job, Argo Workflow) | `kind` (required), `namespace` (required), `name` (required), `container` (optional), `tail_lines` (optional, default 100 per pod), `grep` (optional) |
198198
| `get_cluster_audit` | Static config posture — best-practice findings (Security / Reliability / Efficiency) with remediation. INDEPENDENT of operational health; for "what's broken right now?" use `issues`. | `namespace` (optional), `category` (optional), `severity` (optional) |
199199
| `list_packages` | Installed packages (Helm releases, label-managed workloads, CRDs, Argo Applications, Flux HelmReleases + Kustomizations) with source provenance, versions, and health, in one call. Response includes `sourceLegend` for the stable source codes. | `namespace` (optional), `source` (optional: `H`/`helm`, `L`/`labels`, `C`/`crds`, `A`/`argocd`, `F`/`fluxcd`), `chart` (optional substring) |
200200
| `list_helm_releases` | List Helm releases with status, resource health, storage namespace, Flux ownership, current `lastOperation`, and a capped `operations` trail when Helm history indicates failed upgrades, rollback-after-failure, rollbacks, or stuck pending operations. Use this first for Helm deployment debugging. | `namespace` (optional) |

internal/issues/category.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type classifyInput struct {
1313
APIGroup string // the resource's API group (Issue.Group)
1414
Kind string
1515
Reason string
16+
Message string
1617
LastTerminatedReason string
1718
}
1819

@@ -145,6 +146,9 @@ func classifyProblem(in classifyInput) issuesapi.Category {
145146
case "Service port mismatch":
146147
return issuesapi.CategoryMissingConfigRef
147148
}
149+
if isForbiddenMessage(in.Message) {
150+
return issuesapi.CategoryRBACForbidden
151+
}
148152
switch in.Kind {
149153
case "Pod":
150154
if in.Reason == "OOMKilled" {
@@ -259,6 +263,12 @@ func classifyProblem(in classifyInput) issuesapi.Category {
259263
}
260264
return issuesapi.CategoryUnknown
261265

266+
case "Workflow", "CronWorkflow":
267+
if strings.Contains(strings.ToLower(in.APIGroup), "argoproj.io") && isForbiddenMessage(in.Message) {
268+
return issuesapi.CategoryRBACForbidden
269+
}
270+
return issuesapi.CategoryUnknown
271+
262272
case "Application":
263273
// ArgoCD Application health/sync failure from DetectGitOpsProblems.
264274
// Gate on group so a same-named CRD from another controller can't be
@@ -300,6 +310,11 @@ func classifyProblem(in classifyInput) issuesapi.Category {
300310
return issuesapi.CategoryUnknown
301311
}
302312

313+
func isForbiddenMessage(message string) bool {
314+
m := strings.ToLower(message)
315+
return strings.Contains(m, " is forbidden: user ") && strings.Contains(m, " cannot ")
316+
}
317+
303318
// classifyGitOpsReason maps a GitOps detector/condition reason to a specific
304319
// GitOps failure category. Shared by the Argo + Flux arms of both Classify
305320
// (conditions) and classifyProblem (DetectGitOpsProblems) so the two paths

internal/issues/category_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func TestClassify(t *testing.T) {
4040
{"config error waiting", classifyInput{Source: SourceProblem, Kind: "Pod", Reason: "CreateContainerConfigError"}, issuesapi.CategoryContainerWaiting},
4141
{"pending pod (non-scheduling)", classifyInput{Source: SourceProblem, Kind: "Pod", Reason: "Pending"}, issuesapi.CategoryContainerWaiting},
4242
{"errored pod", classifyInput{Source: SourceProblem, Kind: "Pod", Reason: "Error"}, issuesapi.CategoryCrashLoop},
43+
{"pod status rbac forbidden message", classifyInput{Source: SourceProblem, Kind: "Pod", Reason: "Completed", Message: `workflowtaskresults.argoproj.io is forbidden: User "system:serviceaccount:radar-batch-visual:default" cannot create resource "workflowtaskresults" in API group "argoproj.io" in the namespace "radar-batch-visual"`}, issuesapi.CategoryRBACForbidden},
4344
{"high restart thrash", classifyInput{Source: SourceProblem, Kind: "Pod", Reason: "HighRestartCount"}, issuesapi.CategoryHighRestart},
4445
{"liveness probe failed", classifyInput{Source: SourceProblem, Kind: "Pod", Reason: "LivenessProbeFailed"}, issuesapi.CategoryLivenessProbeFail},
4546
{"liveness probe beats stale oom", classifyInput{Source: SourceProblem, Kind: "Pod", Reason: "LivenessProbeFailed", LastTerminatedReason: "OOMKilled"}, issuesapi.CategoryLivenessProbeFail},
@@ -100,6 +101,8 @@ func TestClassify(t *testing.T) {
100101
{"job stuck active", classifyInput{Source: SourceProblem, Kind: "Job", Reason: "Running for 3h with no completions"}, issuesapi.CategoryJobFailed},
101102
{"cronjob stale", classifyInput{Source: SourceProblem, Kind: "CronJob", Reason: "stale"}, issuesapi.CategoryCronJobFailed},
102103
{"cronjob never scheduled", classifyInput{Source: SourceProblem, Kind: "CronJob", Reason: "never-scheduled"}, issuesapi.CategoryCronJobFailed},
104+
{"argo workflow rbac forbidden", classifyInput{Source: SourceProblem, Kind: "Workflow", APIGroup: "argoproj.io", Reason: "Completed", Message: `workflowtaskresults.argoproj.io is forbidden: User "system:serviceaccount:radar-batch-visual:default" cannot create resource "workflowtaskresults" in API group "argoproj.io" in the namespace "radar-batch-visual"`}, issuesapi.CategoryRBACForbidden},
105+
{"argo workflow completed without forbidden stays unknown", classifyInput{Source: SourceProblem, Kind: "Workflow", APIGroup: "argoproj.io", Reason: "Completed", Message: "child 'example' failed"}, issuesapi.CategoryUnknown},
103106

104107
// missing_ref
105108
{"missing configmap", classifyInput{Source: SourceMissingRef, Kind: "Pod", Reason: "Missing ConfigMap"}, issuesapi.CategoryMissingConfigRef},

internal/issues/issues_test.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,35 @@ func TestCompose_PopulatesCategoryAndGroup(t *testing.T) {
141141
}
142142
}
143143

144+
func TestCompose_ClassifiesArgoWorkflowForbiddenMessage(t *testing.T) {
145+
p := &fakeProvider{
146+
problems: []k8s.Detection{{
147+
Kind: "Pod",
148+
Namespace: "ns",
149+
Name: "daily-import-step-123",
150+
Severity: "critical",
151+
Reason: "Completed",
152+
Message: `workflowtaskresults.argoproj.io is forbidden: User "system:serviceaccount:ns:default" cannot create resource "workflowtaskresults" in API group "argoproj.io" in the namespace "ns"`,
153+
OwnerKind: "Workflow",
154+
OwnerName: "daily-import",
155+
OwnerGroup: "argoproj.io",
156+
}},
157+
}
158+
out := Compose(p, Filters{Grouped: true})
159+
if len(out) != 1 {
160+
t.Fatalf("got %d issues: %+v", len(out), out)
161+
}
162+
if out[0].Kind != "Workflow" || out[0].Name != "daily-import" || out[0].Group != "argoproj.io" {
163+
t.Fatalf("subject = %s/%s/%s, want argoproj.io/Workflow/daily-import", out[0].Group, out[0].Kind, out[0].Name)
164+
}
165+
if out[0].Reason != "RBACForbidden" {
166+
t.Fatalf("reason = %q, want RBACForbidden", out[0].Reason)
167+
}
168+
if out[0].Category != issuesapi.CategoryRBACForbidden || out[0].CategoryGroup != issuesapi.GroupSecurity {
169+
t.Fatalf("category = %q/%q, want %q/%q", out[0].Category, out[0].CategoryGroup, issuesapi.CategoryRBACForbidden, issuesapi.GroupSecurity)
170+
}
171+
}
172+
144173
func TestCompose_GroupsMemberPodsUnderOwner(t *testing.T) {
145174
// Two pods of the same Deployment failing the same way share one issue
146175
// ID (the future collapse target); a third pod failing differently gets
@@ -2020,8 +2049,8 @@ func TestSymptomNamesSecret(t *testing.T) {
20202049
}{
20212050
{`references Secret "foo" which does not exist`, "prod", true},
20222051
{`MountVolume.SetUp failed: secret "foo" not found`, "prod", true},
2023-
{`secrets "foo" not found`, "prod", true}, // plural kubelet path
2024-
{`couldn't find key tls.crt in Secret prod/foo`, "prod", true}, // namespaced missing-key
2052+
{`secrets "foo" not found`, "prod", true}, // plural kubelet path
2053+
{`couldn't find key tls.crt in Secret prod/foo`, "prod", true}, // namespaced missing-key
20252054
{`references ConfigMap "foo" which does not exist`, "prod", false}, // same name, wrong kind
20262055
{`waiting on something else`, "prod", false},
20272056
}

internal/issues/normalize.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,18 @@ func fromProblem(p k8s.Detection, now time.Time, source Source) Issue {
4848
// issue looking fresh. AgeSeconds (resource age) is a stable lower bound.
4949
since = now.Add(-time.Duration(p.AgeSeconds) * time.Second)
5050
}
51+
reason := p.Reason
52+
if isForbiddenMessage(p.Message) {
53+
reason = "RBACForbidden"
54+
}
5155
iss := Issue{
5256
Severity: sev,
5357
Source: source,
5458
Kind: p.Kind,
5559
Group: resolveGroup(p.Group, p.Kind),
5660
Namespace: p.Namespace,
5761
Name: p.Name,
58-
Reason: p.Reason,
62+
Reason: reason,
5963
Message: p.Message,
6064
RawMessage: p.RawMessage,
6165
Cause: p.Cause,
@@ -103,6 +107,7 @@ func classifyIssue(i *Issue) {
103107
APIGroup: i.Group,
104108
Kind: i.Kind,
105109
Reason: i.Reason,
110+
Message: i.Message,
106111
LastTerminatedReason: i.LastTerminatedReason,
107112
})
108113
i.CategoryGroup = issuesapi.GroupOf(i.Category)

internal/k8s/dynamic_cache.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ var supportedCRDFallbacks = []supportedCRDResource{
162162
{Group: "argoproj.io", Versions: []string{"v1alpha1"}, Resource: "rollouts", Kind: "Rollout", Namespaced: true},
163163
{Group: "argoproj.io", Versions: []string{"v1alpha1"}, Resource: "workflows", Kind: "Workflow", Namespaced: true},
164164
{Group: "argoproj.io", Versions: []string{"v1alpha1"}, Resource: "cronworkflows", Kind: "CronWorkflow", Namespaced: true},
165+
{Group: "argoproj.io", Versions: []string{"v1alpha1"}, Resource: "workflowtemplates", Kind: "WorkflowTemplate", Namespaced: true},
166+
{Group: "argoproj.io", Versions: []string{"v1alpha1"}, Resource: "clusterworkflowtemplates", Kind: "ClusterWorkflowTemplate", Namespaced: false},
165167
{Group: "cert-manager.io", Versions: []string{"v1"}, Resource: "certificates", Kind: "Certificate", Namespaced: true},
166168
{Group: "cert-manager.io", Versions: []string{"v1"}, Resource: "issuers", Kind: "Issuer", Namespaced: true},
167169
{Group: "cert-manager.io", Versions: []string{"v1"}, Resource: "clusterissuers", Kind: "ClusterIssuer", Namespaced: false},

internal/k8s/workload.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package k8s
22

33
import (
4+
"context"
45
"fmt"
56

67
corev1 "k8s.io/api/core/v1"
@@ -45,6 +46,30 @@ func GetWorkloadSelector(cache *ResourceCache, kind, namespace, name string) (*m
4546
}
4647
return ds.Spec.Selector, nil
4748

49+
case "job", "jobs":
50+
lister := cache.Jobs()
51+
if lister == nil {
52+
return nil, fmt.Errorf("insufficient permissions to list jobs")
53+
}
54+
job, err := lister.Jobs(namespace).Get(name)
55+
if err != nil {
56+
return nil, fmt.Errorf("job %s/%s not found: %w", namespace, name, err)
57+
}
58+
if job.Spec.Selector == nil {
59+
return &metav1.LabelSelector{
60+
MatchLabels: map[string]string{"batch.kubernetes.io/job-name": name},
61+
}, nil
62+
}
63+
return job.Spec.Selector, nil
64+
65+
case "workflow", "workflows":
66+
if _, err := cache.GetDynamicWithGroup(context.Background(), "Workflow", namespace, name, "argoproj.io"); err != nil {
67+
return nil, fmt.Errorf("workflow %s/%s not found: %w", namespace, name, err)
68+
}
69+
return &metav1.LabelSelector{
70+
MatchLabels: map[string]string{"workflows.argoproj.io/workflow": name},
71+
}, nil
72+
4873
default:
4974
return nil, fmt.Errorf("unsupported workload kind: %s", kind)
5075
}

internal/mcp/tools.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ func registerTools(server *mcp.Server, includeWrites bool) {
450450
mcp.AddTool(server, &mcp.Tool{
451451
Name: "get_workload_logs",
452452
Description: "Get aggregated logs from all pods of a workload (Deployment, StatefulSet, " +
453-
"or DaemonSet). Logs are collected from all matching pods concurrently, then " +
453+
"DaemonSet, Job, or Argo Workflow). Logs are collected from all matching pods concurrently, then " +
454454
"server-side filtered to errors, warnings, panics, and stack traces using " +
455455
"deterministic regex patterns and deduplicated. Set grep for additional " +
456456
"server-side filtering before that summary stage, like `kubectl logs | grep PATTERN`. " +

internal/mcp/tools_filter_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,12 @@ func TestNormalizeWorkloadLogsKind_DefaultsToDeployment(t *testing.T) {
699699
if got := normalizeWorkloadLogsKind("statefulset"); got != "statefulsets" {
700700
t.Fatalf("statefulset workload-log kind = %q, want statefulsets", got)
701701
}
702+
if got := normalizeWorkloadLogsKind("job"); got != "jobs" {
703+
t.Fatalf("job workload-log kind = %q, want jobs", got)
704+
}
705+
if got := normalizeWorkloadLogsKind("Workflow"); got != "workflows" {
706+
t.Fatalf("Workflow workload-log kind = %q, want workflows", got)
707+
}
702708
}
703709

704710
func TestParseLogsSince(t *testing.T) {

0 commit comments

Comments
 (0)