diff --git a/README.md b/README.md index 6a46916..98c3406 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,11 @@ CODAG_CONSOLE=http://localhost:3000 codag auth login After `codag setup`, supported agents can call Codag tools directly instead of fetching raw logs through shell commands. For example, an agent can call -`tail_kubernetes`, `tail_aws_logs`, or the generic `wrap` tool and receive a -compact summary instead of thousands of raw log lines. +`tail_kubernetes` with `target=deployment/prod-api` and `namespace=prod` (or +`tail_aws_logs` / the generic `wrap` tool) and receive a compact summary +instead of thousands of raw log lines. Structured `tail_kubernetes` resolves +pods, fans out current and previous container logs when restarts are detected, +and includes Warning events in one compact call. MCP log tools use the same compact endpoint as `codag wrap`: signed-in workspaces follow the Free/Pro plan, and signed-out tools fall back to the diff --git a/cmd/help_llm.go b/cmd/help_llm.go index e6e3092..e84d28b 100644 --- a/cmd/help_llm.go +++ b/cmd/help_llm.go @@ -34,6 +34,12 @@ If codag MCP tools are available: - Use named tools for supported providers: ` + "`tail_vercel`" + `, ` + "`tail_aws_logs`" + `, ` + "`tail_kubernetes`" + `, ` + "`tail_docker`" + `, ` + "`tail_gh_actions`" + `. +- For Kubernetes incidents prefer structured ` + "`tail_kubernetes`" + ` fields + (` + "`target`" + ` / ` + "`selector`" + ` / ` + "`namespace`" + `) so one call resolves pods, + fetches current + previous logs when containers restarted, includes Warning + events, and returns a single compact summary. Example: + ` + "`target=\"deployment/prod-api\", namespace=\"prod\"`" + `. + Legacy ` + "`args=[...]`" + ` passthrough to ` + "`kubectl logs`" + ` still works. - Use ` + "`wrap`" + ` for local files and provider CLIs without a named tool. - Use ` + "`compact`" + ` only for log text that is already in chat or returned by another tool and cannot be fetched from disk/provider CLI. diff --git a/cmd/help_llm_test.go b/cmd/help_llm_test.go index 1b30dc7..b424016 100644 --- a/cmd/help_llm_test.go +++ b/cmd/help_llm_test.go @@ -22,6 +22,9 @@ func TestLLMDocDocumentsAgentContract(t *testing.T) { "exit code is propagated", // stats line shape "est. tok", + // k8s structured incident path + "target=", + "deployment/prod-api", } { if !strings.Contains(llmDoc, want) { t.Errorf("help-llm doc missing %q", want) diff --git a/docs/tail-kubernetes-demo/01-context.png b/docs/tail-kubernetes-demo/01-context.png new file mode 100644 index 0000000..5b194f0 Binary files /dev/null and b/docs/tail-kubernetes-demo/01-context.png differ diff --git a/docs/tail-kubernetes-demo/02-before-raw.png b/docs/tail-kubernetes-demo/02-before-raw.png new file mode 100644 index 0000000..5cbb8bf Binary files /dev/null and b/docs/tail-kubernetes-demo/02-before-raw.png differ diff --git a/docs/tail-kubernetes-demo/03-structured-fetch.png b/docs/tail-kubernetes-demo/03-structured-fetch.png new file mode 100644 index 0000000..294ddb7 Binary files /dev/null and b/docs/tail-kubernetes-demo/03-structured-fetch.png differ diff --git a/docs/tail-kubernetes-demo/04-after-compact.png b/docs/tail-kubernetes-demo/04-after-compact.png new file mode 100644 index 0000000..bee6849 Binary files /dev/null and b/docs/tail-kubernetes-demo/04-after-compact.png differ diff --git a/docs/tail-kubernetes-demo/05-comparison.png b/docs/tail-kubernetes-demo/05-comparison.png new file mode 100644 index 0000000..1b4871d Binary files /dev/null and b/docs/tail-kubernetes-demo/05-comparison.png differ diff --git a/internal/k8s/fetch.go b/internal/k8s/fetch.go new file mode 100644 index 0000000..e97d11b --- /dev/null +++ b/internal/k8s/fetch.go @@ -0,0 +1,163 @@ +package k8s + +import ( + "context" + "fmt" + "strings" + "time" +) + +// FetchOpts controls a structured multi-pod log (+ optional events) fetch. +type FetchOpts struct { + Namespace string + Target string + Selector string + Since time.Duration + ForcePrevious bool // fetch --previous for every pod + IncludeEvents bool + MaxLines int // 0 = unlimited (still time-bounded by caller ctx) + Runner Runner +} + +// FetchResult is the merged, tagged evidence ready for compaction. +type FetchResult struct { + Lines []string + Pods []PodRef + Notes []string +} + +// FetchIncident resolves pods, fans out kubectl logs (and --previous when +// useful), optionally pulls Warning events, and returns tagged lines. +func FetchIncident(ctx context.Context, opts FetchOpts) (*FetchResult, error) { + if ctx == nil { + ctx = context.Background() + } + run := opts.Runner + if run == nil { + run = DefaultRunner + } + since := opts.Since + if since <= 0 { + since = 30 * time.Minute + } + + pods, err := Resolve(ctx, run, ResolveOpts{ + Namespace: opts.Namespace, + Target: opts.Target, + Selector: opts.Selector, + }) + if err != nil { + return nil, err + } + if len(pods) == 0 { + return nil, fmt.Errorf("no pods matched target=%q selector=%q namespace=%q", + opts.Target, opts.Selector, opts.Namespace) + } + capped := CapPods(pods) + var notes []string + if len(capped) < len(pods) { + notes = append(notes, fmt.Sprintf("matched %d pods; fetching the first %d", len(pods), len(capped))) + } + + var lines []string + emitted := 0 + + for _, p := range capped { + if ctx.Err() != nil { + notes = append(notes, "interrupted while collecting pod logs") + break + } + if opts.MaxLines > 0 && emitted >= opts.MaxLines { + notes = append(notes, fmt.Sprintf("stopped after %d lines (budget exhausted)", emitted)) + break + } + + needPrevious := opts.ForcePrevious + if !needPrevious { + if n, err := PodRestarts(ctx, run, p); err == nil && n > 0 { + needPrevious = true + } + } + + n, err := collectPodLogs(ctx, run, p, since, false, &lines, &emitted, opts.MaxLines) + if err != nil { + notes = append(notes, fmt.Sprintf("%s current logs: %v", p.Ref, err)) + } else if n == 0 { + notes = append(notes, fmt.Sprintf("%s produced no current log lines", p.Ref)) + } + + if needPrevious { + if opts.MaxLines > 0 && emitted >= opts.MaxLines { + break + } + _, prevErr := collectPodLogs(ctx, run, p, since, true, &lines, &emitted, opts.MaxLines) + if prevErr != nil { + // --previous often fails when no prior container exists; note softly. + notes = append(notes, fmt.Sprintf("%s previous logs unavailable: %v", p.Ref, prevErr)) + } + } + } + + if opts.IncludeEvents { + evArgs := EventsArgs(opts.Namespace, since) + out, err := run(ctx, "kubectl", evArgs...) + if err != nil { + notes = append(notes, fmt.Sprintf("events: %v", err)) + } else { + raw := strings.Split(string(out), "\n") + kept := FilterRecentEvents(raw, since, time.Now()) + for _, line := range kept { + if opts.MaxLines > 0 && emitted >= opts.MaxLines { + break + } + lines = append(lines, TagEventLine(line)) + emitted++ + } + } + } + + if len(lines) == 0 { + msg := "no log lines collected from matched pods" + if len(notes) > 0 { + msg += ": " + strings.Join(notes, "; ") + } + return nil, fmt.Errorf("%s", msg) + } + + return &FetchResult{Lines: lines, Pods: capped, Notes: notes}, nil +} + +func collectPodLogs( + ctx context.Context, + run Runner, + p PodRef, + since time.Duration, + previous bool, + lines *[]string, + emitted *int, + budget int, +) (int, error) { + args := LogArgs(p, since, previous) + out, err := run(ctx, "kubectl", args...) + if err != nil { + return 0, err + } + n := 0 + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimRight(line, "\r") + if strings.TrimSpace(line) == "" { + continue + } + msg := line + if previous { + msg = "[previous] " + line + } + *lines = append(*lines, TagLogLine(p, msg)) + *emitted++ + n++ + if budget > 0 && *emitted >= budget { + break + } + } + return n, nil +} diff --git a/internal/k8s/k8s.go b/internal/k8s/k8s.go new file mode 100644 index 0000000..0ce8f6c --- /dev/null +++ b/internal/k8s/k8s.go @@ -0,0 +1,429 @@ +// Package k8s builds read-only kubectl invocations and resolves pods for +// Codag's onboard warm path and the MCP tail_kubernetes tool. +package k8s + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// MaxPods caps how many pods a single fan-out will touch. Mirrors the +// historical onboard limit so multi-tenant clusters stay bounded. +const MaxPods = 30 + +// PodRef is one pod to fetch logs from. +type PodRef struct { + Namespace string // empty = kubectl's current namespace + Ref string // "pod/name" +} + +// Name returns the bare pod name (without the "pod/" prefix). +func (p PodRef) Name() string { + return strings.TrimPrefix(p.Ref, "pod/") +} + +// Runner executes kubectl (or a test double). +type Runner func(ctx context.Context, name string, args ...string) ([]byte, error) + +// DefaultRunner shells out to the named binary. Used in production. +func DefaultRunner(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) // #nosec G204 -- fixed kubectl binary; args are constructed by this package. + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + return nil, err + } + lines := strings.Split(msg, "\n") + if len(lines) > 8 { + lines = lines[len(lines)-8:] + } + return nil, fmt.Errorf("%w: %s", err, strings.Join(lines, "\n")) + } + return stdout.Bytes(), nil +} + +// LogArgs builds `kubectl logs` for one pod. when previous is true, adds +// --previous so CrashLoopBackOff pods still yield useful evidence. +func LogArgs(p PodRef, since time.Duration, previous bool) []string { + args := []string{"logs", p.Ref, "--since=" + FormatSince(since), "--all-containers=true"} + if previous { + args = append(args, "--previous") + } + if p.Namespace != "" { + args = append(args, "-n", p.Namespace) + } + return args +} + +// EventsArgs builds a Warning-events query for the namespace (or current +// context namespace when ns is empty). +func EventsArgs(ns string, since time.Duration) []string { + args := []string{ + "get", "events", + "--field-selector=type=Warning", + "--sort-by=.lastTimestamp", + "-o", "custom-columns=LAST:.lastTimestamp,TYPE:.type,REASON:.reason,OBJECT:.involvedObject.kind/.involvedObject.name,MESSAGE:.message", + "--no-headers", + } + if ns != "" { + args = append(args, "-n", ns) + } + // kubectl get events has no --since; callers filter by timestamp after. + _ = since + return args +} + +// CanListPodsArgs builds `kubectl auth can-i list pods` for Detect. +func CanListPodsArgs(pinnedNS string, allNamespaces bool) []string { + args := []string{"auth", "can-i", "list", "pods", "--quiet", "--request-timeout=5s"} + if pinnedNS != "" { + return append(args, "-n", pinnedNS) + } + if allNamespaces { + return append(args, "--all-namespaces") + } + return args +} + +// ListPodsOpts controls the onboard-style pod listing. +type ListPodsOpts struct { + PinnedNamespace string // CODAG_K8S_NAMESPACE + AllNamespaces bool // CODAG_K8S_ALL_NAMESPACES=1 +} + +// ListPods lists pods for the onboard warm path (current ns, pinned, or -A). +func ListPods(ctx context.Context, run Runner, opts ListPodsOpts) ([]PodRef, error) { + if run == nil { + run = DefaultRunner + } + args := []string{"get", "pods", "-o", "name"} + allNamespaces := opts.PinnedNamespace == "" && opts.AllNamespaces + if opts.PinnedNamespace != "" { + args = append(args, "-n", opts.PinnedNamespace) + } else if allNamespaces { + args = []string{"get", "pods", "-A", "-o", `jsonpath={range .items[*]}{.metadata.namespace}{"\t"}{.metadata.name}{"\n"}{end}`} + } + stdout, err := run(ctx, "kubectl", args...) + if err != nil { + return nil, err + } + var out []PodRef + for _, line := range strings.Split(string(stdout), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if allNamespaces { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + out = append(out, PodRef{Namespace: fields[0], Ref: "pod/" + fields[1]}) + continue + } + out = append(out, PodRef{Namespace: opts.PinnedNamespace, Ref: line}) + } + return out, nil +} + +// ResolveOpts is the structured MCP/incident resolution input. +type ResolveOpts struct { + Namespace string // optional; empty = current context ns + Target string // pod/X, deployment/X, sts/X, statefulset/X, or bare pod name + Selector string // label selector, e.g. app=api +} + +// Resolve turns a target or selector into pod refs. Prefer Selector when +// both are set only if Target is empty — Target and Selector are mutually +// exclusive at the call site. +func Resolve(ctx context.Context, run Runner, opts ResolveOpts) ([]PodRef, error) { + if run == nil { + run = DefaultRunner + } + ns := strings.TrimSpace(opts.Namespace) + target := strings.TrimSpace(opts.Target) + selector := strings.TrimSpace(opts.Selector) + + if target == "" && selector == "" { + return nil, fmt.Errorf("k8s resolve requires target or selector") + } + if target != "" && selector != "" { + return nil, fmt.Errorf("k8s resolve: pass target or selector, not both") + } + + if selector != "" { + if !isSafeSelector(selector) { + return nil, fmt.Errorf("k8s resolve: unsafe selector %q", selector) + } + return listPodsBySelector(ctx, run, ns, selector) + } + + kind, name, err := parseTarget(target) + if err != nil { + return nil, err + } + switch kind { + case "pod": + ref := PodRef{Namespace: ns, Ref: "pod/" + name} + return []PodRef{ref}, nil + case "deployment", "deploy": + sel, err := workloadSelector(ctx, run, ns, "deployment", name) + if err != nil { + return nil, err + } + return listPodsBySelector(ctx, run, ns, sel) + case "statefulset", "sts": + sel, err := workloadSelector(ctx, run, ns, "statefulset", name) + if err != nil { + return nil, err + } + return listPodsBySelector(ctx, run, ns, sel) + default: + return nil, fmt.Errorf("k8s resolve: unsupported target kind %q (want pod, deployment, or statefulset)", kind) + } +} + +func workloadSelector(ctx context.Context, run Runner, ns, kind, name string) (string, error) { + args := []string{"get", kind, name, "-o", `jsonpath={.spec.selector.matchLabels}`} + if ns != "" { + args = append(args, "-n", ns) + } + out, err := run(ctx, "kubectl", args...) + if err != nil { + return "", fmt.Errorf("get %s/%s selector: %w", kind, name, err) + } + sel := matchLabelsToSelector(string(out)) + if sel == "" { + return "", fmt.Errorf("%s/%s has no matchLabels selector", kind, name) + } + return sel, nil +} + +// matchLabelsToSelector turns kubectl jsonpath map output into a label +// selector. Handles both legacy `map[app:api]` and JSON `{"app":"api"}`. +func matchLabelsToSelector(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "{}" { + return "" + } + // JSON object: {"app":"api","tier":"front"} + if strings.HasPrefix(raw, "{") && strings.Contains(raw, `"`) { + raw = strings.TrimPrefix(raw, "{") + raw = strings.TrimSuffix(raw, "}") + var parts []string + for _, pair := range strings.Split(raw, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + kv := strings.SplitN(pair, ":", 2) + if len(kv) != 2 { + continue + } + k := strings.Trim(kv[0], ` "`) + v := strings.Trim(kv[1], ` "`) + if k == "" { + continue + } + parts = append(parts, k+"="+v) + } + return strings.Join(parts, ",") + } + // Go map print: map[app:api tier:front] + raw = strings.TrimPrefix(raw, "map[") + raw = strings.TrimSuffix(raw, "]") + var parts []string + for _, pair := range strings.Fields(raw) { + kv := strings.SplitN(pair, ":", 2) + if len(kv) != 2 { + continue + } + parts = append(parts, kv[0]+"="+kv[1]) + } + return strings.Join(parts, ",") +} + +func listPodsBySelector(ctx context.Context, run Runner, ns, selector string) ([]PodRef, error) { + args := []string{"get", "pods", "-l", selector, "-o", "name"} + if ns != "" { + args = append(args, "-n", ns) + } + stdout, err := run(ctx, "kubectl", args...) + if err != nil { + return nil, fmt.Errorf("list pods -l %s: %w", selector, err) + } + var out []PodRef + for _, line := range strings.Split(string(stdout), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if !strings.HasPrefix(line, "pod/") { + line = "pod/" + line + } + out = append(out, PodRef{Namespace: ns, Ref: line}) + } + return out, nil +} + +// parseTarget accepts "pod/x", "deployment/x", "deploy/x", "sts/x", +// "statefulset/x", or a bare safe pod name. +func parseTarget(target string) (kind, name string, err error) { + target = strings.TrimSpace(target) + if target == "" { + return "", "", fmt.Errorf("empty target") + } + if i := strings.Index(target, "/"); i >= 0 { + kind = strings.ToLower(target[:i]) + name = target[i+1:] + } else { + kind = "pod" + name = target + } + if !IsSafeIdentifier(name) { + return "", "", fmt.Errorf("unsafe target name %q", name) + } + switch kind { + case "pod", "deployment", "deploy", "statefulset", "sts": + return kind, name, nil + default: + return "", "", fmt.Errorf("unsupported target kind %q", kind) + } +} + +// PodRestarts reports restartCount for the first container (best-effort). +// Used to decide whether to also fetch --previous logs. +func PodRestarts(ctx context.Context, run Runner, p PodRef) (int, error) { + if run == nil { + run = DefaultRunner + } + args := []string{"get", p.Ref, "-o", `jsonpath={.status.containerStatuses[0].restartCount}`} + if p.Namespace != "" { + args = append(args, "-n", p.Namespace) + } + out, err := run(ctx, "kubectl", args...) + if err != nil { + return 0, err + } + n := 0 + fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &n) + return n, nil +} + +// EnvListOpts reads CODAG_K8S_* from the environment for onboard. +func EnvListOpts() ListPodsOpts { + return ListPodsOpts{ + PinnedNamespace: strings.TrimSpace(os.Getenv("CODAG_K8S_NAMESPACE")), + AllNamespaces: os.Getenv("CODAG_K8S_ALL_NAMESPACES") == "1", + } +} + +// FormatSince turns a duration into kubectl --since=Nh (minimum 1h for +// sub-hour values — matches historical onboard formatSince). +func FormatSince(d time.Duration) string { + hours := int(d.Hours()) + if hours <= 0 { + hours = 1 + } + return fmt.Sprintf("%dh", hours) +} + +// FormatSinceFlexible accepts agent-facing strings like "30m", "2h", "1h30m". +// Falls back to 30m on empty/invalid. +func FormatSinceFlexible(s string) time.Duration { + s = strings.TrimSpace(s) + if s == "" { + return 30 * time.Minute + } + d, err := time.ParseDuration(s) + if err != nil || d <= 0 { + return 30 * time.Minute + } + return d +} + +// IsSafeIdentifier allows DNS-1123-ish names (letters, digits, -, ., _). +func IsSafeIdentifier(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || + r == '_' || r == '-' || r == '.' { + continue + } + return false + } + return true +} + +func isSafeSelector(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || + r == '_' || r == '-' || r == '.' || r == '=' || r == ',' || r == '!' { + continue + } + return false + } + return true +} + +// CapPods truncates to MaxPods. +func CapPods(pods []PodRef) []PodRef { + if len(pods) <= MaxPods { + return pods + } + return pods[:MaxPods] +} + +// TagLogLine prefixes a log line with pod identity for multi-pod compact. +func TagLogLine(pod PodRef, line string) string { + return fmt.Sprintf("[pod=%s] %s", pod.Name(), line) +} + +// TagEventLine prefixes a Warning event line. +func TagEventLine(line string) string { + return "[event] " + line +} + +// FilterRecentEvents keeps event lines whose leading timestamp is within +// since of now. Lines without a parseable RFC3339 / kubectl timestamp are +// kept (better to over-include than drop evidence). +func FilterRecentEvents(lines []string, since time.Duration, now time.Time) []string { + if since <= 0 { + return lines + } + cutoff := now.Add(-since) + var out []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + ts, err := time.Parse(time.RFC3339, fields[0]) + if err != nil { + // kubectl sometimes prints relative ages; keep those. + out = append(out, line) + continue + } + if !ts.Before(cutoff) { + out = append(out, line) + } + } + return out +} diff --git a/internal/k8s/k8s_test.go b/internal/k8s/k8s_test.go new file mode 100644 index 0000000..e3cd543 --- /dev/null +++ b/internal/k8s/k8s_test.go @@ -0,0 +1,272 @@ +package k8s + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestLogArgs(t *testing.T) { + got := strings.Join(LogArgs(PodRef{Ref: "pod/api-0"}, 24*time.Hour, false), " ") + want := "logs pod/api-0 --since=24h --all-containers=true" + if got != want { + t.Fatalf("LogArgs = %q, want %q", got, want) + } + + got = strings.Join(LogArgs(PodRef{Namespace: "prod", Ref: "pod/api-0"}, time.Hour, true), " ") + want = "logs pod/api-0 --since=1h --all-containers=true --previous -n prod" + if got != want { + t.Fatalf("LogArgs previous = %q, want %q", got, want) + } +} + +func TestCanListPodsArgs(t *testing.T) { + got := strings.Join(CanListPodsArgs("", false), " ") + want := "auth can-i list pods --quiet --request-timeout=5s" + if got != want { + t.Fatalf("CanListPodsArgs default = %q, want %q", got, want) + } + if got := strings.Join(CanListPodsArgs("prod", false), " "); !strings.HasSuffix(got, "-n prod") { + t.Fatalf("CanListPodsArgs pinned = %q", got) + } + if got := strings.Join(CanListPodsArgs("", true), " "); !strings.HasSuffix(got, "--all-namespaces") { + t.Fatalf("CanListPodsArgs all = %q", got) + } +} + +func TestEventsArgs(t *testing.T) { + got := strings.Join(EventsArgs("prod", 30*time.Minute), " ") + if !strings.Contains(got, "get events") || !strings.Contains(got, "type=Warning") { + t.Fatalf("EventsArgs missing Warning filter: %q", got) + } + if !strings.HasSuffix(got, "-n prod") { + t.Fatalf("EventsArgs ns = %q", got) + } +} + +func TestParseTarget(t *testing.T) { + cases := []struct { + in string + kind, name string + ok bool + }{ + {"pod/api-0", "pod", "api-0", true}, + {"deployment/prod-api", "deployment", "prod-api", true}, + {"deploy/prod-api", "deploy", "prod-api", true}, + {"sts/redis", "sts", "redis", true}, + {"statefulset/redis", "statefulset", "redis", true}, + {"api-0", "pod", "api-0", true}, + {"job/batch", "", "", false}, + {"pod/evil;rm", "", "", false}, + {"", "", "", false}, + } + for _, c := range cases { + kind, name, err := parseTarget(c.in) + if c.ok { + if err != nil || kind != c.kind || name != c.name { + t.Fatalf("parseTarget(%q) = (%q,%q,%v), want (%q,%q,nil)", c.in, kind, name, err, c.kind, c.name) + } + } else if err == nil { + t.Fatalf("parseTarget(%q) = nil error, want rejection", c.in) + } + } +} + +func TestMatchLabelsToSelector(t *testing.T) { + cases := []struct { + in, want string + }{ + {`{"app":"api","tier":"front"}`, "app=api,tier=front"}, + {"map[app:api tier:front]", "app=api,tier=front"}, + {"{}", ""}, + {"", ""}, + } + for _, c := range cases { + if got := matchLabelsToSelector(c.in); got != c.want { + t.Fatalf("matchLabelsToSelector(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestResolve_PodTarget(t *testing.T) { + run := func(ctx context.Context, name string, args ...string) ([]byte, error) { + t.Fatalf("unexpected kubectl %v", args) + return nil, nil + } + pods, err := Resolve(context.Background(), run, ResolveOpts{Namespace: "prod", Target: "api-0"}) + if err != nil { + t.Fatal(err) + } + if len(pods) != 1 || pods[0].Ref != "pod/api-0" || pods[0].Namespace != "prod" { + t.Fatalf("pods = %+v", pods) + } +} + +func TestResolve_DeploymentUsesSelector(t *testing.T) { + var calls []string + run := func(ctx context.Context, name string, args ...string) ([]byte, error) { + calls = append(calls, strings.Join(args, " ")) + joined := strings.Join(args, " ") + switch { + case strings.HasPrefix(joined, "get deployment prod-api"): + return []byte(`{"app":"prod-api"}`), nil + case strings.HasPrefix(joined, "get pods -l app=prod-api"): + return []byte("pod/prod-api-aaa\npod/prod-api-bbb\n"), nil + default: + t.Fatalf("unexpected: %s", joined) + return nil, nil + } + } + pods, err := Resolve(context.Background(), run, ResolveOpts{ + Namespace: "prod", + Target: "deployment/prod-api", + }) + if err != nil { + t.Fatal(err) + } + if len(pods) != 2 { + t.Fatalf("pods = %+v", pods) + } + if !strings.Contains(calls[0], "get deployment prod-api") { + t.Fatalf("first call = %q", calls[0]) + } + if !strings.Contains(calls[1], "-l app=prod-api") || !strings.Contains(calls[1], "-n prod") { + t.Fatalf("second call = %q", calls[1]) + } +} + +func TestResolve_Selector(t *testing.T) { + run := func(ctx context.Context, name string, args ...string) ([]byte, error) { + if strings.Join(args, " ") != "get pods -l app=api -o name -n prod" { + t.Fatalf("unexpected: %v", args) + } + return []byte("pod/api-1\n"), nil + } + pods, err := Resolve(context.Background(), run, ResolveOpts{Namespace: "prod", Selector: "app=api"}) + if err != nil || len(pods) != 1 || pods[0].Ref != "pod/api-1" { + t.Fatalf("pods=%+v err=%v", pods, err) + } +} + +func TestResolve_RejectsBothAndUnsafe(t *testing.T) { + if _, err := Resolve(context.Background(), nil, ResolveOpts{Target: "p", Selector: "a=b"}); err == nil { + t.Fatal("expected both target+selector rejection") + } + if _, err := Resolve(context.Background(), nil, ResolveOpts{Selector: "app=api;rm"}); err == nil { + t.Fatal("expected unsafe selector rejection") + } + if _, err := Resolve(context.Background(), nil, ResolveOpts{}); err == nil { + t.Fatal("expected empty rejection") + } +} + +func TestListPods_PinnedAndAllNS(t *testing.T) { + run := func(ctx context.Context, name string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + if strings.Contains(joined, "-A") { + return []byte("prod\tapi-0\ndefault\tweb-1\n"), nil + } + if strings.Contains(joined, "-n prod") { + return []byte("pod/api-0\n"), nil + } + t.Fatalf("unexpected: %s", joined) + return nil, nil + } + pods, err := ListPods(context.Background(), run, ListPodsOpts{PinnedNamespace: "prod"}) + if err != nil || len(pods) != 1 || pods[0].Namespace != "prod" { + t.Fatalf("pinned: %+v %v", pods, err) + } + pods, err = ListPods(context.Background(), run, ListPodsOpts{AllNamespaces: true}) + if err != nil || len(pods) != 2 || pods[0].Namespace != "prod" || pods[1].Ref != "pod/web-1" { + t.Fatalf("all: %+v %v", pods, err) + } +} + +func TestFilterRecentEvents(t *testing.T) { + now := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + lines := []string{ + "2026-07-25T11:50:00Z Warning BackOff pod/api", + "2026-07-25T10:00:00Z Warning Old pod/api", + "35s Warning FailedScheduling pod/api", + } + got := FilterRecentEvents(lines, 30*time.Minute, now) + if len(got) != 2 { + t.Fatalf("got %v", got) + } +} + +func TestTagLines(t *testing.T) { + if got := TagLogLine(PodRef{Ref: "pod/api-0"}, "boom"); got != "[pod=api-0] boom" { + t.Fatalf("TagLogLine = %q", got) + } + if got := TagEventLine("Warning BackOff"); got != "[event] Warning BackOff" { + t.Fatalf("TagEventLine = %q", got) + } +} + +func TestFormatSinceFlexible(t *testing.T) { + if d := FormatSinceFlexible("45m"); d != 45*time.Minute { + t.Fatalf("45m = %v", d) + } + if d := FormatSinceFlexible(""); d != 30*time.Minute { + t.Fatalf("default = %v", d) + } + if d := FormatSinceFlexible("nope"); d != 30*time.Minute { + t.Fatalf("invalid = %v", d) + } +} + +func TestCapPods(t *testing.T) { + var pods []PodRef + for i := 0; i < 40; i++ { + pods = append(pods, PodRef{Ref: "pod/x"}) + } + if got := CapPods(pods); len(got) != MaxPods { + t.Fatalf("len = %d", len(got)) + } +} + +func TestFetchIncident_DeploymentWithPreviousAndEvents(t *testing.T) { + now := time.Now().UTC().Format(time.RFC3339) + run := func(ctx context.Context, name string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.HasPrefix(joined, "get deployment prod-api"): + return []byte(`{"app":"prod-api"}`), nil + case strings.HasPrefix(joined, "get pods -l app=prod-api"): + return []byte("pod/prod-api-aaa\n"), nil + case strings.Contains(joined, "restartCount"): + return []byte("2"), nil + case strings.HasPrefix(joined, "logs pod/prod-api-aaa") && strings.Contains(joined, "--previous"): + return []byte("previous boom\n"), nil + case strings.HasPrefix(joined, "logs pod/prod-api-aaa"): + return []byte("current boom\n"), nil + case strings.HasPrefix(joined, "get events"): + return []byte(now + " Warning BackOff pod/prod-api-aaa CrashLoop\n"), nil + default: + t.Fatalf("unexpected kubectl %v", args) + return nil, nil + } + } + res, err := FetchIncident(context.Background(), FetchOpts{ + Namespace: "prod", + Target: "deployment/prod-api", + Since: 30 * time.Minute, + IncludeEvents: true, + Runner: run, + }) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(res.Lines, "\n") + for _, want := range []string{ + "[pod=prod-api-aaa] current boom", + "[pod=prod-api-aaa] [previous] previous boom", + "[event] " + now + " Warning BackOff", + } { + if !strings.Contains(joined, want) { + t.Fatalf("missing %q in:\n%s", want, joined) + } + } +} diff --git a/internal/mcp/dispatch.go b/internal/mcp/dispatch.go index 099eddd..0c01328 100644 --- a/internal/mcp/dispatch.go +++ b/internal/mcp/dispatch.go @@ -154,6 +154,34 @@ func runChildAndCompact(ctx context.Context, cmd string, args []string, level, s return withNotes(resp.Text, notes), nil } +// compactCollectedLines compacts an already-fetched line buffer (e.g. the +// multi-pod k8s fan-out) without spawning another child. +func compactCollectedLines(ctx context.Context, lines []string, notes []string, level, service, incidentType, src string, deterministic bool) (string, error) { + if len(lines) == 0 { + return "", fmt.Errorf("no log lines collected") + } + if ctx == nil { + ctx = context.Background() + } + if passthrough.ShouldPassThrough(len(lines), passthrough.TotalEstTokens(lines)) { + return withNotes(passthrough.RawText(lines), notes), nil + } + records := linesToRecords(lines, level, service) + req := api.CapsuleRequest{Lines: records} + if deterministic { + req.Engine = "deterministic" + } + if strings.TrimSpace(src) == "" { + src = "kubectl" + } + req.Metadata = source.Metadata(incidentType, src) + resp, err := planAwareCompact(ctx, req) + if err != nil { + return "", wrapAPIError(err) + } + return withNotes(resp.Text, notes), nil +} + // withNotes appends agent-addressed caveat lines to a tool result. func withNotes(text string, notes []string) string { if len(notes) == 0 { diff --git a/internal/mcp/kubernetes.go b/internal/mcp/kubernetes.go new file mode 100644 index 0000000..bd6e8da --- /dev/null +++ b/internal/mcp/kubernetes.go @@ -0,0 +1,142 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/codag-megalith/codag-cli/internal/k8s" +) + +func registerKubernetesTool(s *Server) { + s.Register(Tool{ + Name: "tail_kubernetes", + Description: `Tail Kubernetes logs and return a compact incident summary. + +PREFER structured fields when the user names a deployment, statefulset, +label selector, or namespace — one call resolves pods, fans out logs +(including --previous for restarted/CrashLoop pods), pulls Warning events, +and compacts everything together. + +Structured examples: + target="deployment/prod-api", namespace="prod" + target="prod-api-7f9c", namespace="prod", since="1h" + selector="app=api", namespace="prod", previous=true + target="sts/redis", namespace="cache", include_events=false + +Legacy passthrough (raw kubectl logs args) still works when you already +know the exact invocation: + args=["my-pod"] + args=["deployment/api", "--tail=500", "-n", "prod"] + args=["-l", "app=api", "--since=10m", "--all-containers=true"] + +The underlying fetch is always read-only kubectl.`, + InputSchema: k8sToolSchema(), + Annotations: longRunningAnnotations("tail_kubernetes"), + Handler: func(ctx ToolCtx, args map[string]any) (string, error) { + return runTailKubernetes(ctx.Context, args) + }, + }) +} + +func k8sToolSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "target": map[string]interface{}{ + "type": "string", + "description": "Workload or pod: deployment/NAME, deploy/NAME, sts/NAME, statefulset/NAME, pod/NAME, or bare pod name.", + }, + "selector": map[string]interface{}{ + "type": "string", + "description": "Label selector (e.g. app=api). Mutually exclusive with target.", + }, + "namespace": map[string]interface{}{ + "type": "string", + "description": "Kubernetes namespace (-n). Defaults to CODAG_K8S_NAMESPACE or the current context namespace.", + }, + "since": map[string]interface{}{ + "type": "string", + "description": "Lookback window (Go duration, e.g. 30m, 2h). Default 30m in structured mode.", + }, + "previous": map[string]interface{}{ + "type": "boolean", + "description": "Force kubectl logs --previous on every matched pod. Default false; structured mode still auto-fetches previous when restartCount > 0.", + }, + "include_events": map[string]interface{}{ + "type": "boolean", + "description": "Include recent Warning events from the namespace. Default true in structured mode.", + }, + "args": map[string]interface{}{ + "type": "array", + "description": "Legacy: flags + positional args passed to `kubectl logs` verbatim.", + "items": map[string]interface{}{"type": "string"}, + }, + "deterministic": map[string]interface{}{ + "type": "boolean", + "description": deterministicDesc, + }, + }, + } +} + +func runTailKubernetes(ctx context.Context, args map[string]any) (string, error) { + if ctx == nil { + ctx = context.Background() + } + deterministic := boolArg(args, "deterministic") + + target := strArg(args, "target") + selector := strArg(args, "selector") + structured := target != "" || selector != "" + + if !structured { + pass := strSliceArg(args, "args") + if len(pass) == 0 { + return "", fmt.Errorf("tail_kubernetes requires structured target/selector, or legacy args for `kubectl logs`") + } + full := append([]string{"logs"}, pass...) + if err := validateLogCommand("kubectl", full); err != nil { + return "", err + } + return runChildAndCompact(ctx, "kubectl", full, "info", "", "tail_kubernetes", deterministic) + } + + ns := strArg(args, "namespace") + if ns == "" { + ns = strings.TrimSpace(os.Getenv("CODAG_K8S_NAMESPACE")) + } + if ns != "" && !k8s.IsSafeIdentifier(ns) { + return "", fmt.Errorf("unsafe namespace %q", ns) + } + + includeEvents := true + if _, ok := args["include_events"]; ok { + includeEvents = boolArg(args, "include_events") + } + + fetchCtx, cancel := context.WithTimeout(ctx, mcpTimeout) + defer cancel() + + res, err := k8s.FetchIncident(fetchCtx, k8s.FetchOpts{ + Namespace: ns, + Target: target, + Selector: selector, + Since: k8s.FormatSinceFlexible(strArg(args, "since")), + ForcePrevious: boolArg(args, "previous"), + IncludeEvents: includeEvents, + MaxLines: 0, + }) + if err != nil { + return "", err + } + + notes := append([]string{}, res.Notes...) + if err := fetchCtx.Err(); err != nil { + notes = append(notes, fmt.Sprintf("collection hit the %s limit; the summary covers the %d lines collected", mcpTimeout, len(res.Lines))) + } + notes = append(notes, fmt.Sprintf("fetched logs from %d pod(s)", len(res.Pods))) + + return compactCollectedLines(ctx, res.Lines, notes, "info", "", "tail_kubernetes", "kubectl", deterministic) +} diff --git a/internal/mcp/kubernetes_test.go b/internal/mcp/kubernetes_test.go new file mode 100644 index 0000000..a1318ef --- /dev/null +++ b/internal/mcp/kubernetes_test.go @@ -0,0 +1,116 @@ +package mcp + +import ( + "context" + "strings" + "testing" +) + +func TestRunTailKubernetes_LegacyPassthroughValidates(t *testing.T) { + _, err := runTailKubernetes(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected error when neither structured fields nor args are set") + } + if !strings.Contains(err.Error(), "target/selector") && !strings.Contains(err.Error(), "legacy args") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunTailKubernetes_LegacyRejectsMutating(t *testing.T) { + // Structured fields absent; args that would become kubectl logs delete … + // still go through validateLogCommand via base "logs" prefix — delete + // isn't a valid logs shape when passed as args to the passthrough. + // Explicitly ensure a bare mutating attempt via wrong structured target fails. + _, err := runTailKubernetes(context.Background(), map[string]any{ + "target": "job/evil;rm", + }) + if err == nil { + t.Fatal("expected unsafe/unsupported target rejection") + } +} + +func TestRunTailKubernetes_StructuredRejectsBoth(t *testing.T) { + _, err := runTailKubernetes(context.Background(), map[string]any{ + "target": "deployment/api", + "selector": "app=api", + }) + if err == nil { + t.Fatal("expected target+selector rejection") + } + if !strings.Contains(err.Error(), "not both") { + t.Fatalf("error = %v", err) + } +} + +func TestRunTailKubernetes_StructuredUnsafeNamespace(t *testing.T) { + _, err := runTailKubernetes(context.Background(), map[string]any{ + "target": "pod/api", + "namespace": "prod;rm", + }) + if err == nil || !strings.Contains(err.Error(), "unsafe namespace") { + t.Fatalf("error = %v", err) + } +} + +func TestK8sToolSchemaHasStructuredFields(t *testing.T) { + schema := k8sToolSchema() + props, ok := schema["properties"].(map[string]interface{}) + if !ok { + t.Fatal("missing properties") + } + for _, key := range []string{"target", "selector", "namespace", "since", "previous", "include_events", "args", "deterministic"} { + if _, ok := props[key]; !ok { + t.Fatalf("schema missing %q", key) + } + } +} + +func TestRegisterAllExposesTailKubernetes(t *testing.T) { + s := NewServer("test") + RegisterAll(s) + found := false + for _, name := range s.toolNames() { + if name == "tail_kubernetes" { + found = true + break + } + } + if !found { + t.Fatal("tail_kubernetes not registered") + } +} + +// toolNames is a test helper — Server doesn't export the map. +func (s *Server) toolNames() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, 0, len(s.tools)) + for name := range s.tools { + out = append(out, name) + } + return out +} + +func TestCompactCollectedLines_Empty(t *testing.T) { + _, err := compactCollectedLines(context.Background(), nil, nil, "info", "", "tail_kubernetes", "kubectl", true) + if err == nil { + t.Fatal("expected empty error") + } +} + +func TestCompactCollectedLines_PassthroughTiny(t *testing.T) { + // Tiny buffers skip the API and return raw text. + got, err := compactCollectedLines(context.Background(), + []string{"[pod=api] hello"}, + []string{"fetched logs from 1 pod(s)"}, + "info", "", "tail_kubernetes", "kubectl", true) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "[pod=api] hello") { + t.Fatalf("got %q", got) + } + if !strings.Contains(got, "[codag] note: fetched logs from 1 pod(s)") { + t.Fatalf("missing note in %q", got) + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index a4316b3..c4e6e64 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -20,17 +20,7 @@ func RegisterAll(s *Server) { `args=["my-deployment-url", "--scope=team-xyz"]`, }, }) - registerCLITool(s, cliTool{ - name: "tail_kubernetes", - summary: "Tail logs from a Kubernetes pod / deployment / namespace and return a compact summary.", - bin: "kubectl", - baseArgs: []string{"logs"}, - examples: []string{ - `args=["my-pod"]`, - `args=["deployment/api", "--tail=500", "-n", "prod"]`, - `args=["-l", "app=api", "--since=10m", "--all-containers=true"]`, - }, - }) + registerKubernetesTool(s) registerCLITool(s, cliTool{ name: "tail_aws_logs", summary: "Tail an AWS CloudWatch log group and return a compact summary.", diff --git a/internal/onboard/sources/cli_args_test.go b/internal/onboard/sources/cli_args_test.go index c65b02b..2896632 100644 --- a/internal/onboard/sources/cli_args_test.go +++ b/internal/onboard/sources/cli_args_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "github.com/codag-megalith/codag-cli/internal/k8s" ) // These pin the exact provider-CLI invocations the headline MCP/onboard @@ -31,13 +33,16 @@ func TestDockerLogArgs(t *testing.T) { } func TestK8sLogArgs(t *testing.T) { - got := strings.Join(k8sLogArgs(podRef{ref: "pod/api-0"}, 24*time.Hour), " ") + // Command construction for kubectl lives in internal/k8s; onboard + // delegates to it. Pin the shared builders here so a regression in + // the onboard wiring still fails this package's tests. + got := strings.Join(k8s.LogArgs(k8s.PodRef{Ref: "pod/api-0"}, 24*time.Hour, false), " ") want := "logs pod/api-0 --since=24h --all-containers=true" if got != want { t.Fatalf("k8sLogArgs (no ns) = %q, want %q", got, want) } - got = strings.Join(k8sLogArgs(podRef{ns: "prod", ref: "pod/api-0"}, time.Hour), " ") + got = strings.Join(k8s.LogArgs(k8s.PodRef{Namespace: "prod", Ref: "pod/api-0"}, time.Hour, false), " ") want = "logs pod/api-0 --since=1h --all-containers=true -n prod" if got != want { t.Fatalf("k8sLogArgs (ns) = %q, want %q", got, want) @@ -47,20 +52,22 @@ func TestK8sLogArgs(t *testing.T) { func TestK8sCanListPodsArgs(t *testing.T) { t.Setenv("CODAG_K8S_NAMESPACE", "") t.Setenv("CODAG_K8S_ALL_NAMESPACES", "") - got := strings.Join(k8sCanListPodsArgs(), " ") + got := strings.Join(k8s.CanListPodsArgs("", false), " ") want := "auth can-i list pods --quiet --request-timeout=5s" if got != want { t.Fatalf("k8sCanListPodsArgs (default) = %q, want %q", got, want) } t.Setenv("CODAG_K8S_NAMESPACE", "prod") - if got := strings.Join(k8sCanListPodsArgs(), " "); !strings.HasSuffix(got, "-n prod") { + opts := k8s.EnvListOpts() + if got := strings.Join(k8s.CanListPodsArgs(opts.PinnedNamespace, opts.AllNamespaces), " "); !strings.HasSuffix(got, "-n prod") { t.Fatalf("k8sCanListPodsArgs (pinned ns) = %q, want -n prod suffix", got) } t.Setenv("CODAG_K8S_NAMESPACE", "") t.Setenv("CODAG_K8S_ALL_NAMESPACES", "1") - if got := strings.Join(k8sCanListPodsArgs(), " "); !strings.HasSuffix(got, "--all-namespaces") { + opts = k8s.EnvListOpts() + if got := strings.Join(k8s.CanListPodsArgs(opts.PinnedNamespace, opts.AllNamespaces), " "); !strings.HasSuffix(got, "--all-namespaces") { t.Fatalf("k8sCanListPodsArgs (all ns) = %q, want --all-namespaces suffix", got) } } diff --git a/internal/onboard/sources/kubernetes.go b/internal/onboard/sources/kubernetes.go index f59b393..4b9dc39 100644 --- a/internal/onboard/sources/kubernetes.go +++ b/internal/onboard/sources/kubernetes.go @@ -3,11 +3,11 @@ package sources import ( "context" "fmt" - "strings" "time" "github.com/codag-megalith/codag-cli/internal/config" "github.com/codag-megalith/codag-cli/internal/exec" + "github.com/codag-megalith/codag-cli/internal/k8s" "github.com/codag-megalith/codag-cli/internal/onboard" ) @@ -24,7 +24,8 @@ func (k8sSource) Detect(_ *config.Config) (bool, string) { if err := commandRun(ctx, "kubectl", "config", "current-context"); err != nil { return false, "no kubeconfig context: run `kubectl config use-context `" } - if err := commandRun(ctx, "kubectl", k8sCanListPodsArgs()...); err != nil { + opts := k8s.EnvListOpts() + if err := commandRun(ctx, "kubectl", k8s.CanListPodsArgs(opts.PinnedNamespace, opts.AllNamespaces)...); err != nil { return false, "current kube context cannot list pods; check namespace/RBAC or set CODAG_K8S_NAMESPACE" } return true, "" @@ -35,13 +36,11 @@ func (k8sSource) Detect(_ *config.Config) (bool, string) { // namespace via CODAG_K8S_NAMESPACE keeps onboarding scoped on // multi-tenant clusters. func (k8sSource) Fetch(ctx context.Context, _ *config.Config, opts onboard.FetchOpts) (<-chan string, error) { - pods, err := k8sListPods(ctx) + pods, err := k8s.ListPods(ctx, k8s.DefaultRunner, k8s.EnvListOpts()) if err != nil { return nil, fmt.Errorf("list pods: %w", err) } - if len(pods) > 30 { - pods = pods[:30] - } + pods = k8s.CapPods(pods) if len(pods) == 0 { ch := make(chan string) close(ch) @@ -58,9 +57,9 @@ func (k8sSource) Fetch(ctx context.Context, _ *config.Config, opts onboard.Fetch if opts.MaxLines > 0 && emitted >= opts.MaxLines { return } - s, err := exec.StreamStdout(ctx, "kubectl", k8sLogArgs(p, opts.Since), perPod) + s, err := exec.StreamStdout(ctx, "kubectl", k8s.LogArgs(p, opts.Since, false), perPod) if err != nil { - logFetchErr("kubernetes/"+p.ref, err) + logFetchErr("kubernetes/"+p.Ref, err) continue } for line := range s.Lines { @@ -75,69 +74,11 @@ func (k8sSource) Fetch(ctx context.Context, _ *config.Config, opts onboard.Fetch } } if err := s.Wait(); err != nil { - logFetchErr("kubernetes/"+p.ref, err) + logFetchErr("kubernetes/"+p.Ref, err) } } }() return out, nil } -type podRef struct { - ns string - ref string // "pod/name" -} - -// k8sLogArgs builds the `kubectl logs` invocation for one pod. -func k8sLogArgs(p podRef, since time.Duration) []string { - args := []string{"logs", p.ref, "--since=" + formatSince(since), "--all-containers=true"} - if p.ns != "" { - args = append(args, "-n", p.ns) - } - return args -} - -func k8sListPods(ctx context.Context) ([]podRef, error) { - pinned := strings.TrimSpace(getenv("CODAG_K8S_NAMESPACE")) - args := []string{"get", "pods", "-o", "name"} - allNamespaces := pinned == "" && getenv("CODAG_K8S_ALL_NAMESPACES") == "1" - if pinned != "" { - args = append(args, "-n", pinned) - } else if allNamespaces { - args = []string{"get", "pods", "-A", "-o", `jsonpath={range .items[*]}{.metadata.namespace}{"\t"}{.metadata.name}{"\n"}{end}`} - } - stdout, err := commandOutput(ctx, "kubectl", args...) - if err != nil { - return nil, err - } - var out []podRef - for _, line := range strings.Split(string(stdout), "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if allNamespaces { - fields := strings.Fields(line) - if len(fields) != 2 { - continue - } - out = append(out, podRef{ns: fields[0], ref: "pod/" + fields[1]}) - continue - } - out = append(out, podRef{ns: pinned, ref: line}) - } - return out, nil -} - -func k8sCanListPodsArgs() []string { - args := []string{"auth", "can-i", "list", "pods", "--quiet", "--request-timeout=5s"} - pinned := strings.TrimSpace(getenv("CODAG_K8S_NAMESPACE")) - if pinned != "" { - return append(args, "-n", pinned) - } - if getenv("CODAG_K8S_ALL_NAMESPACES") == "1" { - return append(args, "--all-namespaces") - } - return args -} - func init() { onboard.Register(k8sSource{}) } diff --git a/internal/setup/setup.go b/internal/setup/setup.go index b787d73..afd6fb3 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -39,7 +39,7 @@ func Run(opts Options) error { fmt.Println() fmt.Println("Try in your agent:") fmt.Println(` "tail my Vercel logs from the last hour"`) - fmt.Println(` "show me errors in the prod-api kubernetes deployment"`) + fmt.Println(` "show me errors in the prod-api kubernetes deployment" # uses tail_kubernetes target=deployment/prod-api`) fmt.Println(` "what's wrong with GitHub Actions run 12345678"`) fmt.Println() fmt.Println("Sign in later for Pro compaction and account features:")