diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb96740..c4f5e2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,9 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -160,6 +163,7 @@ jobs: with: context: . push: false + platforms: linux/amd64,linux/arm64 tags: ghcr.io/optiqor/kerno:ci cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/enforce-issue-cap.yml b/.github/workflows/enforce-issue-cap.yml index 45c58c1..f4ed4cc 100644 --- a/.github/workflows/enforce-issue-cap.yml +++ b/.github/workflows/enforce-issue-cap.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Find over-cap contributors and trim - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const repo = { owner: context.repo.owner, repo: context.repo.repo }; diff --git a/.github/workflows/gssoc-difficulty.yml b/.github/workflows/gssoc-difficulty.yml new file mode 100644 index 0000000..954852d --- /dev/null +++ b/.github/workflows/gssoc-difficulty.yml @@ -0,0 +1,141 @@ +# Copyright 2026 Optiqor contributors +# SPDX-License-Identifier: Apache-2.0 +# +# Auto-applies a level:* difficulty label to every PR. One label per PR. +# Re-runs on synchronize, so a PR that grows out of its initial bucket +# gets re-classified on each push. +# +# Classification (strict; checked top-to-bottom, first match wins): +# +# level:critical — touches BPF C, AI, daemon entry, release tooling, +# install script, Dockerfile, security workflows, +# or K8s RBAC / security context. Size-independent. +# +# level:beginner — ONLY trivial paths (docs, templates, fixtures, +# .gitignore) AND ≤ 5 files. Any size in that scope. +# A README rewrite is beginner. A typo is beginner. +# +# level:advanced — 400+ total lines OR 9+ files; OR a code path +# (internal/, pkg/, cmd/) with 100+ lines or 5+ files. +# +# level:intermediate — everything else. Default bucket for code work +# under 100 lines, and for non-code config tweaks +# that aren't trivial docs. +# +# If you need to override, comment with /level — not wired yet +# but the override path is reserved. + +name: GSSoC difficulty label + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + issues: write # /issues/:n/labels endpoint is under the issues resource + pull-requests: write + +jobs: + classify: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + const pr = context.payload.pull_request; + const prNumber = pr.number; + + const files = await github.paginate(github.rest.pulls.listFiles, { + ...repo, pull_number: prNumber, per_page: 100, + }); + const paths = files.map(f => f.filename); + + // ── Critical surface (size-independent) ──────────────────── + // + // A bug here can cause kernel-verifier rejection, capability + // widening, secret leakage, or a bad release artifact. + const criticalPatterns = [ + /^internal\/bpf\/c\//, // BPF C source — verifier risk + /^internal\/bpf\/.*loader\.go$/, // BPF program loaders + /^internal\/ai\//, // privacy + API key handling + /^internal\/cli\/start\.go$/, // daemon entry + capabilities + /^Dockerfile/, // production image + /^scripts\/install\.sh$/, // root install + supply chain + /^SECURITY\.md$/, + /^\.goreleaser/, + /^\.github\/workflows\/(release|codeql|cosign|security)/, + /^deploy\/.*(rbac|security[Cc]ontext|securitycontext)/, + ]; + + // ── Code paths (intermediate floor) ──────────────────────── + // + // Any non-trivial change in these paths is at least intermediate. + // Touching the doctor engine, a collector, or a CLI command + // requires understanding the codebase — not beginner work. + const codePathPatterns = [ + /^internal\//, + /^pkg\//, + /^cmd\//, + ]; + + // ── Trivial paths (beginner ceiling) ──────────────────────── + // + // PRs that touch ONLY these paths qualify for beginner regardless + // of line count. Docs, config, fixtures, issue/PR templates. + const trivialPatterns = [ + /\.md$/, + /\.gitignore$/, + /\.editorconfig$/, + /^docs\//, + /^testdata\//, + /^demo\./, + /^\.github\/ISSUE_TEMPLATE\//, + /^\.github\/PULL_REQUEST_TEMPLATE\.md$/, + /^\.github\/mlc-config\.json$/, + /^\.github\/labeler\.yml$/, + /^\.github\/dependabot\.yml$/, + /^LICENSE$/, + /^CODE_OF_CONDUCT\.md$/, + /^GOVERNANCE\.md$/, + ]; + + const lines = (pr.additions || 0) + (pr.deletions || 0); + const fileCount = paths.length; + const touchesCritical = paths.some(p => criticalPatterns.some(re => re.test(p))); + const touchesCode = paths.some(p => codePathPatterns.some(re => re.test(p))); + const allTrivial = paths.length > 0 && paths.every(p => trivialPatterns.some(re => re.test(p))); + + let level; + if (touchesCritical) { + level = 'level:critical'; + } else if (allTrivial && fileCount <= 5) { + level = 'level:beginner'; + } else if (lines >= 400 || fileCount >= 9) { + level = 'level:advanced'; + } else if (touchesCode && (lines >= 100 || fileCount >= 5)) { + level = 'level:advanced'; + } else if (touchesCode) { + level = 'level:intermediate'; + } else { + level = 'level:intermediate'; + } + + core.info( + `PR #${prNumber}: ${lines} lines, ${fileCount} files, ` + + `critical=${touchesCritical}, code=${touchesCode}, allTrivial=${allTrivial} → ${level}` + ); + + // Strip any other level:* label, then apply the chosen one. + const currentLabels = (pr.labels || []).map(l => l.name); + for (const old of currentLabels) { + if (old.startsWith('level:') && old !== level) { + await github.rest.issues.removeLabel({ + ...repo, issue_number: prNumber, name: old, + }).catch(() => {}); + } + } + await github.rest.issues.addLabels({ + ...repo, issue_number: prNumber, labels: [level], + }); diff --git a/.github/workflows/gssoc-mentor.yml b/.github/workflows/gssoc-mentor.yml new file mode 100644 index 0000000..1e046ce --- /dev/null +++ b/.github/workflows/gssoc-mentor.yml @@ -0,0 +1,66 @@ +# Copyright 2026 Optiqor contributors +# SPDX-License-Identifier: Apache-2.0 +# +# When a maintainer (repo collaborator with write+) submits an APPROVED +# review, apply `mentor:` to the PR. The scoring engine +# uses this label to credit the reviewing mentor with points. +# +# Multiple maintainers approving = multiple mentor:* labels. That is +# intentional: every reviewer who signed off gets credit. + +name: GSSoC mentor attribution + +on: + pull_request_review: + types: [submitted] + +permissions: + contents: read + issues: write # /repos/:o/:r/labels and /issues/:n/labels live under the issues resource + pull-requests: write + +jobs: + attribute: + if: github.event.review.state == 'approved' + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + const reviewer = context.payload.review.user.login; + const prNumber = context.payload.pull_request.number; + + // Only collaborators with write+ count as mentors. + let isMaintainer = false; + try { + const perm = await github.rest.repos.getCollaboratorPermissionLevel({ + ...repo, username: reviewer, + }); + const lvl = perm.data.permission; + isMaintainer = (lvl === 'admin' || lvl === 'write' || lvl === 'maintain'); + } catch (e) { + core.info(`@${reviewer} not a collaborator; skipping mentor attribution`); + return; + } + if (!isMaintainer) { + core.info(`@${reviewer} lacks write+; skipping`); + return; + } + + const label = `mentor:${reviewer}`; + + // Create the label on the fly so we don't need to pre-register + // every maintainer's username. Pastel gray so they don't shout. + await github.rest.issues.createLabel({ + ...repo, + name: label, + color: 'C5C5C5', + description: `Reviewed and approved by @${reviewer}`, + }).catch(() => {}); // label may already exist + + await github.rest.issues.addLabels({ + ...repo, issue_number: prNumber, labels: [label], + }); + + core.info(`applied ${label} to PR #${prNumber}`); diff --git a/.github/workflows/helm-release.yml b/.github/workflows/helm-release.yml new file mode 100644 index 0000000..91b973b --- /dev/null +++ b/.github/workflows/helm-release.yml @@ -0,0 +1,48 @@ +# Copyright 2026 Optiqor contributors +# SPDX-License-Identifier: Apache-2.0 + +name: Helm Chart Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release-chart: + name: Release Chart + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Install Helm + uses: azure/setup-helm@v4 + + - name: Run chart-releaser + uses: helm/chart-releaser-action@v1 + with: + charts_dir: deploy/helm + env: + CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + # After chart-releaser finishes, it has updated gh-pages. + # We need to ensure artifacthub-repo.yml is in the root of gh-pages. + - name: Push Artifact Hub metadata + run: | + cp artifacthub-repo.yml /tmp/ahr.yml + git checkout gh-pages + cp /tmp/ahr.yml ./artifacthub-repo.yml + git add artifacthub-repo.yml + git commit -m "chore: update artifacthub-repo.yml" || echo "No changes to commit" + git push origin gh-pages diff --git a/.github/workflows/pr-commands.yml b/.github/workflows/pr-commands.yml index a48903e..8e610fb 100644 --- a/.github/workflows/pr-commands.yml +++ b/.github/workflows/pr-commands.yml @@ -42,7 +42,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Parse and dispatch - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const body = context.payload.comment.body || ''; diff --git a/.github/workflows/welcome-commenter.yml b/.github/workflows/welcome-commenter.yml new file mode 100644 index 0000000..7f4d715 --- /dev/null +++ b/.github/workflows/welcome-commenter.yml @@ -0,0 +1,52 @@ +# Copyright 2026 Optiqor contributors +# SPDX-License-Identifier: Apache-2.0 +# +# Welcomes a contributor on their first issue comment. Sister to +# welcome.yml, which only fires on issue/PR open. + +name: Welcome first-time commenters + +on: + issue_comment: + types: [created] + +permissions: + issues: write + +jobs: + welcome: + if: | + github.event.comment.user.type != 'Bot' && + github.event.comment.user.login != github.repository_owner && + github.event.comment.user.login != github.event.issue.user.login && + !github.event.issue.pull_request + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + const commenter = context.payload.comment.user.login; + const issueNumber = context.payload.issue.number; + + // Skip if the user has already commented on a different issue. + const res = await github.rest.search.issuesAndPullRequests({ + q: `repo:${repo.owner}/${repo.repo} commenter:${commenter} is:issue`, + per_page: 10, + }); + if ((res.data.items || []).some(i => i.number !== issueNumber)) { + core.info(`@${commenter} is not a first-time commenter; skipping`); + return; + } + + const body = + `Hey @${commenter}, welcome. Looks like this is your first comment in the repo.\n\n` + + `Want to work on this issue? Reply with \`/assign\` or \`/take\` to claim it.\n\n` + + `If the work's been useful, two quick ways to help:\n\n` + + `⭐ [Star kerno](https://github.com/optiqor/kerno): eBPF kernel diagnosis engine\n` + + `⭐ [Star optiqor-cli](https://github.com/optiqor/optiqor-cli): Kubernetes cost remediation that lives in the pull request\n\n` + + `Thanks for showing up.`; + + await github.rest.issues.createComment({ + ...repo, issue_number: issueNumber, body, + }); diff --git a/.gitignore b/.gitignore index 6e3fdb9..853f035 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ Thumbs.db # Debug __debug_bin* +# Man pages (generatable via make manpage) +docs/man/*.1 + # Environment .env .env.local diff --git a/Makefile b/Makefile index 3e64e1c..b284b89 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ UI_DIST_DIR := internal/dashboard/dist/assets .PHONY: all build build-ebpf build-debug test test-cover test-race lint vet check \ fmt clean bpf generate docker help \ ui-fetch ui-dev install-tools setup precommit \ - verify demo demo-cast bpf-verify + verify demo demo-cast bpf-verify manpage .DEFAULT_GOAL := help @@ -167,6 +167,13 @@ docker: --build-arg DATE=$(DATE) \ . +# ─── Man Pages ──────────────────────────────────────────────────────────────── + +## manpage: Generate man pages for all CLI commands +manpage: + @mkdir -p docs/man + go run ./cmd/kerno-mangen/ + # ─── Utilities ─────────────────────────────────────────────────────────────── ## install-tools: Install Go-based development tools (golangci-lint, bpf2go) diff --git a/README.md b/README.md index bc1ece7..87dc97a 100644 --- a/README.md +++ b/README.md @@ -118,12 +118,16 @@ Kerno is the only eBPF tool in the Kubernetes ecosystem that produces a ranked, ### 1 · Kubernetes (primary) ```bash -helm install kerno ./deploy/helm/kerno \ +helm repo add kerno https://optiqor.github.io/kerno-charts +helm repo update +helm install kerno kerno/kerno \ -n kerno-system --create-namespace ``` Within 30 seconds Kerno is running as a DaemonSet on every node, watching the kernel via eBPF, exposing `/metrics` for Prometheus, and ready for `kerno doctor`. +> **Tip:** If you prefer to install from a local clone: `helm install kerno ./deploy/helm/kerno -n kerno-system --create-namespace` + ```bash # Cluster-wide incident report - 30 seconds of real kernel data kubectl -n kerno-system exec ds/kerno -- kerno doctor @@ -167,7 +171,7 @@ docker run --rm --privileged --pid=host \ ghcr.io/optiqor/kerno:latest doctor ``` -Multi-arch (`linux/amd64`, `linux/arm64`) images published to GHCR on every release. +Multi-arch (`linux/amd64`, `linux/arm64`) images published to GHCR on every release. Graviton, Apple Silicon, and Raspberry Pi clusters work out of the box. --- diff --git a/artifacthub-repo.yml b/artifacthub-repo.yml new file mode 100644 index 0000000..6c65b41 --- /dev/null +++ b/artifacthub-repo.yml @@ -0,0 +1,11 @@ +# Artifact Hub repository metadata +# https://artifacthub.io/docs/topics/repositories/helm-charts/ +# +# To register this chart on Artifact Hub: +# 1. Go to https://artifacthub.io and sign in. +# 2. Add a new Helm Charts repository with the URL: +# https://optiqor.github.io/kerno-charts +# 3. Artifact Hub will generate a repositoryID (UUID). +# 4. Uncomment and fill in the lines below, then open a PR. +# +# repositoryID: diff --git a/cmd/kerno-mangen/main.go b/cmd/kerno-mangen/main.go new file mode 100644 index 0000000..9c91e58 --- /dev/null +++ b/cmd/kerno-mangen/main.go @@ -0,0 +1,34 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +// Package main generates man pages for kerno CLI commands. +package main + +import ( + "log" + "os" + + "github.com/spf13/cobra/doc" + + "github.com/optiqor/kerno/internal/cli" +) + +func main() { + root := cli.New() + manDir := "docs/man" + + if err := os.MkdirAll(manDir, 0o750); err != nil { + log.Fatalf("creating man dir: %v", err) + } + + header := &doc.GenManHeader{ + Title: "KERNO", + Section: "1", + } + + if err := doc.GenManTree(root, header, manDir); err != nil { + log.Fatalf("generating man pages: %v", err) + } + + log.Printf("Generated man pages in %s", manDir) +} diff --git a/deploy/helm/kerno/Chart.yaml b/deploy/helm/kerno/Chart.yaml index 4e527b5..acb9530 100644 --- a/deploy/helm/kerno/Chart.yaml +++ b/deploy/helm/kerno/Chart.yaml @@ -1,18 +1,26 @@ -apiVersion: v2 -name: kerno -description: eBPF-based kernel observability engine for Kubernetes -type: application -version: 0.1.0 -appVersion: "0.1.0" -home: https://github.com/optiqor/kerno -sources: - - https://github.com/optiqor/kerno -maintainers: - - name: Shivam Kumar - url: https://github.com/btwshivam -keywords: - - ebpf - - observability - - kernel - - prometheus - - monitoring +apiVersion: v2 +name: kerno +description: eBPF-based kernel observability engine for Kubernetes +type: application +version: 0.1.0 +appVersion: "0.1.0" +home: https://github.com/optiqor/kerno +sources: + - https://github.com/optiqor/kerno +maintainers: + - name: Shivam Kumar + url: https://github.com/btwshivam +keywords: + - ebpf + - observability + - kernel + - prometheus + - monitoring +annotations: + artifacthub.io/license: Apache-2.0 + artifacthub.io/signKey: "" + artifacthub.io/containsSecurityUpdates: "false" + artifacthub.io/prerelease: "false" + # artifacthub.io/maintainers annotation intentionally omitted: + # Artifact Hub reads the standard maintainers: field above automatically. + # icon: intentionally omitted until a proper static PNG/SVG logo is available. diff --git a/deploy/helm/kerno/README.md b/deploy/helm/kerno/README.md new file mode 100644 index 0000000..ba56c31 --- /dev/null +++ b/deploy/helm/kerno/README.md @@ -0,0 +1,54 @@ +# Kerno Helm Chart + +Kerno is an eBPF-based kernel observability engine for Kubernetes. It diagnoses production incidents by watching kernel signals (disk, TCP, OOM, scheduler) and providing a ranked diagnostic report. + +## Prerequisites + +- Kubernetes 1.22+ +- Helm 3.8.0+ +- Linux kernel 5.8+ with BTF enabled (standard on EKS, GKE, AKS, etc.) + +## Installation + +```bash +helm repo add kerno https://optiqor.github.io/kerno-charts +helm repo update +helm install kerno kerno/kerno -n kerno-system --create-namespace +``` + +## Configuration + +The following table lists the most common configurable parameters of the Kerno chart and their default values. + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `image.repository` | Image repository | `ghcr.io/optiqor/kerno` | +| `image.tag` | Image tag | `{{ .Chart.AppVersion }}` | +| `resources.requests.cpu` | CPU requests | `100m` | +| `resources.requests.memory` | Memory requests | `128Mi` | +| `prometheus.enabled` | Enable Prometheus metrics | `true` | +| `collectors.syscallLatency` | Enable syscall latency collector | `true` | +| `collectors.tcpMonitor` | Enable TCP monitor collector | `true` | +| `collectors.oomTrack` | Enable OOM tracker | `true` | +| `collectors.diskIO` | Enable Disk I/O collector | `true` | +| `collectors.schedDelay` | Enable scheduler delay collector | `true` | +| `collectors.fdTrack` | Enable file descriptor tracker | `true` | + +For a full list of parameters, see [values.yaml](values.yaml). + +## Examples + +### Enable AI Diagnosis +```bash +helm install kerno kerno/kerno \ + --set extraEnv[0].name=KERNO_AI_API_KEY \ + --set extraEnv[0].value=your-key \ + --set extraEnv[1].name=KERNO_AI_PROVIDER \ + --set extraEnv[1].value=anthropic +``` + +## Version Compatibility Matrix + +| Kerno Version | K8s Version | Kernel Version | +|---------------|-------------|----------------| +| v0.1.x | 1.22 - 1.31 | 5.8+ | diff --git a/go.mod b/go.mod index 1820dc0..956a0dd 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -22,6 +23,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect diff --git a/go.sum b/go.sum index dcecec5..1992f4a 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cilium/ebpf v0.21.0 h1:4dpx1J/B/1apeTmWBH5BkVLayHTkFrMovVPnHEk+l3k= github.com/cilium/ebpf v0.21.0/go.mod h1:1kHKv6Kvh5a6TePP5vvvoMa1bclRyzUXELSs272fmIQ= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -51,6 +52,7 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..7a2067e --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,78 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "github.com/spf13/cobra" +) + +func newCompletionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Long: `Generate shell completion scripts for kerno. + +Kerno uses spf13/cobra's built-in completion generation which supports +bash, zsh, fish, and powershell. + +To load completions: + +Bash: + + $ source <(kerno completion bash) + + # To load completions for each session, execute once: + $ echo 'source <(kerno completion bash)' >> ~/.bashrc + +Zsh: + + # If shell completion is not already enabled in your zsh environment, + # you need to enable it. You can execute the following once: + + $ echo 'autoload -U compinit; compinit' >> ~/.zshrc + + # To load completions for each session, execute once: + $ kerno completion zsh > "${fpath[1]}/_kerno" + + # You will need to start a new shell for this setup to take effect. + +Fish: + + $ kerno completion fish | source + + # To load completions for each session, execute once: + $ kerno completion fish > ~/.config/fish/completions/kerno.fish + +PowerShell: + + PS> kerno completion powershell > kerno.ps1 + # and source this file from your PowerShell profile. + +Alternatively, specify the shell with the first argument: + + $ kerno completion bash + $ kerno completion zsh + $ kerno completion fish + $ kerno completion powershell +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletion(cmd.OutOrStdout()) + case "zsh": + return cmd.Root().GenZshCompletion(cmd.OutOrStdout()) + case "fish": + return cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(cmd.OutOrStdout()) + } + return nil + }, + } + + return cmd +} diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go new file mode 100644 index 0000000..a4d49a8 --- /dev/null +++ b/internal/cli/completion_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "testing" +) + +func TestCompletionCmd(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "powershell"} { + t.Run(shell, func(t *testing.T) { + cmd := New() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"completion", shell}) + if err := cmd.Execute(); err != nil { + t.Fatalf("%s: %v", shell, err) + } + if buf.Len() == 0 { + t.Errorf("%s: empty completion output", shell) + } + }) + } +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 74ce50e..aeecc8f 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -13,6 +13,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/optiqor/kerno/internal/adapter" "github.com/optiqor/kerno/internal/ai" @@ -32,6 +33,7 @@ func newDoctorCmd() *cobra.Command { useAI bool noAI bool quiet bool + noBanner bool ) cmd := &cobra.Command{ @@ -80,6 +82,7 @@ Add --ai to enrich findings with AI-powered analysis (requires API key).`, output: output, aiEnabled: aiEnabled, quiet: quiet, + noBanner: noBanner, }) }, } @@ -93,6 +96,12 @@ Add --ai to enrich findings with AI-powered analysis (requires API key).`, flags.BoolVar(&useAI, "ai", false, "enable AI-powered analysis (requires API key)") flags.BoolVar(&noAI, "no-ai", false, "disable AI analysis even if enabled in config") flags.BoolVarP(&quiet, "quiet", "q", false, "only emit critical/warning findings (CI-friendly)") + flags.BoolVar(&noBanner, "no-banner", false, "suppress the ASCII banner block") + + //nolint:errcheck // RegisterFlagCompletionFunc only returns error on invalid flag name, which is static. + _ = cmd.RegisterFlagCompletionFunc("output", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"pretty", "json"}, cobra.ShellCompDirectiveNoFileComp + }) return cmd } @@ -105,6 +114,7 @@ type doctorOpts struct { output string aiEnabled bool quiet bool + noBanner bool } func runDoctor(ctx context.Context, opts doctorOpts) error { @@ -146,7 +156,8 @@ func runDoctor(ctx context.Context, opts doctorOpts) error { renderer = &doctor.JSONRenderer{Pretty: true} default: renderer = &doctor.PrettyRenderer{ - NoColor: os.Getenv("NO_COLOR") != "" || !isTerminal(), + NoColor: viper.GetBool("no_color") || os.Getenv("NO_COLOR") != "" || !isTerminal(), + NoBanner: opts.noBanner, } } diff --git a/internal/cli/doctor_flags_test.go b/internal/cli/doctor_flags_test.go index 5b556cf..fe5dd36 100644 --- a/internal/cli/doctor_flags_test.go +++ b/internal/cli/doctor_flags_test.go @@ -13,7 +13,7 @@ import ( func TestNewDoctorCmd_Flags(t *testing.T) { cmd := newDoctorCmd() - wantFlags := []string{"duration", "exit-code", "continuous", "interval", "output", "ai", "no-ai"} + wantFlags := []string{"duration", "exit-code", "continuous", "interval", "output", "ai", "no-ai", "no-banner"} for _, name := range wantFlags { if cmd.Flags().Lookup(name) == nil { t.Errorf("doctor cmd missing --%s flag", name) @@ -43,6 +43,7 @@ func TestNewDoctorCmd_Defaults(t *testing.T) { {"continuous", "false"}, {"ai", "false"}, {"no-ai", "false"}, + {"no-banner", "false"}, } for _, c := range cases { f := cmd.Flags().Lookup(c.flag) diff --git a/internal/cli/root.go b/internal/cli/root.go index 6db7b23..ae81213 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -83,6 +83,7 @@ and copy-paste fix steps.`, auditCmd := newAuditCmd() chaosCmd := newChaosCmd() versionCmd := newVersionCmd() + completionCmd := newCompletionCmd() root.AddGroup( &cobra.Group{ID: "diagnose", Title: "Incident diagnosis:"}, @@ -98,8 +99,9 @@ and copy-paste fix steps.`, startCmd.GroupID = "ops" chaosCmd.GroupID = "ops" versionCmd.GroupID = "ops" + completionCmd.GroupID = "ops" - root.AddCommand(doctorCmd, explainCmd, predictCmd, traceCmd, watchCmd, auditCmd, startCmd, chaosCmd, versionCmd) + root.AddCommand(doctorCmd, explainCmd, predictCmd, traceCmd, watchCmd, auditCmd, startCmd, chaosCmd, versionCmd, completionCmd) return root } @@ -133,10 +135,15 @@ func initConfig(cmd *cobra.Command) error { if err := v.BindPFlag("log_level", cmd.Root().PersistentFlags().Lookup("log-level")); err != nil { return fmt.Errorf("binding log-level flag: %w", err) } + if err := v.BindPFlag("log_format", cmd.Root().PersistentFlags().Lookup("log-format")); err != nil { return fmt.Errorf("binding log-format flag: %w", err) } + if err := v.BindPFlag("no_color", cmd.Root().PersistentFlags().Lookup("no-color")); err != nil { + return fmt.Errorf("binding no-color flag: %w", err) + } + // Read config file (not an error if it doesn't exist). if err := v.ReadInConfig(); err != nil { var notFound viper.ConfigFileNotFoundError diff --git a/internal/doctor/render.go b/internal/doctor/render.go index afdb62b..5dcc394 100644 --- a/internal/doctor/render.go +++ b/internal/doctor/render.go @@ -22,7 +22,8 @@ type Renderer interface { // PrettyRenderer outputs a human-readable incident report with ANSI colors, // box-drawn finding cards, and bar-chart signal visualizations. type PrettyRenderer struct { - NoColor bool + NoColor bool + NoBanner bool } const ( @@ -67,7 +68,11 @@ func newPalette(noColor bool) palette { func (r *PrettyRenderer) Render(w io.Writer, report *Report) error { p := newPalette(r.NoColor) - r.renderHeader(w, report, p) + + if !r.NoBanner { + r.renderHeader(w, report, p) + } + r.renderDegradation(w, report, p) r.renderTriage(w, report, p) for i := range report.Findings { diff --git a/internal/doctor/render_test.go b/internal/doctor/render_test.go index 9d7678a..3140ed2 100644 --- a/internal/doctor/render_test.go +++ b/internal/doctor/render_test.go @@ -84,6 +84,27 @@ func TestPrettyRenderer_ContainsHeader(t *testing.T) { } } +func TestPrettyRenderer_NoBanner(t *testing.T) { + var buf bytes.Buffer + // Set NoBanner to true + r := &PrettyRenderer{NoColor: true, NoBanner: true} + report := sampleReport() + + if err := r.Render(&buf, report); err != nil { + t.Fatalf("Render failed: %v", err) + } + + output := buf.String() + + if strings.Contains(output, "KERNO DOCTOR") { + t.Error("pretty output should NOT contain KERNO DOCTOR banner when NoBanner is true") + } + + if !strings.Contains(output, "FINDINGS") { + t.Error("pretty output should still contain FINDINGS") + } +} + func TestPrettyRenderer_HealthySystem(t *testing.T) { var buf bytes.Buffer r := &PrettyRenderer{NoColor: true} diff --git a/scripts/install.sh b/scripts/install.sh index a417eb9..1c74863 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -30,17 +30,20 @@ CONFIG_DIR="/etc/kerno" # ── Parse arguments ────────────────────────────────────────────────── VERSION="" INSTALL_DAEMON=false +INSTALL_COMPLETION=true while [[ $# -gt 0 ]]; do case "$1" in --version) VERSION="$2"; shift 2 ;; --daemon) INSTALL_DAEMON=true; shift ;; + --no-completion) INSTALL_COMPLETION=false; shift ;; --help|-h) echo "Usage: curl -sfL https://raw.githubusercontent.com/optiqor/kerno/main/scripts/install.sh | bash -s -- [OPTIONS]" echo "" echo "Options:" echo " --version VERSION Install a specific version (default: latest)" echo " --daemon Also install systemd service for daemon mode" + echo " --no-completion Skip shell completion installation" echo " --help Show this help" exit 0 ;; @@ -88,6 +91,59 @@ check_root() { fi } +# ── Shell completion installation ────────────────────────────────── +detect_shell() { + local shell + shell="${SHELL##*/}" + case "$shell" in + bash) echo "bash" ;; + zsh) echo "zsh" ;; + fish) echo "fish" ;; + *) echo "" ;; + esac +} + +install_completion() { + local shell + shell=$(detect_shell) + + if [ -z "$shell" ]; then + echo "==> Shell completion: could not detect shell (SHELL=$SHELL)" + echo " Manually enable completion: https://github.com/optiqor/kerno#shell-completion" + return + fi + + echo "" + echo "==> Installing shell completion for $shell..." + + case "$shell" in + bash) + local bash_dir="/etc/bash_completion.d" + mkdir -p "$bash_dir" + "${INSTALL_DIR}/kerno" completion bash > "${bash_dir}/kerno" + chmod 644 "${bash_dir}/kerno" + echo " Installed to ${bash_dir}/kerno" + echo " Restart shell or run: source ${bash_dir}/kerno" + ;; + zsh) + local zsh_dir="/usr/local/share/zsh/site-functions" + mkdir -p "$zsh_dir" + "${INSTALL_DIR}/kerno" completion zsh > "${zsh_dir}/_kerno" + chmod 644 "${zsh_dir}/_kerno" + echo " Installed to ${zsh_dir}/_kerno" + echo " Restart shell or run: autoload -U compinit && compinit" + ;; + fish) + local fish_dir="/usr/share/fish/vendor_completions.d" + mkdir -p "$fish_dir" + "${INSTALL_DIR}/kerno" completion fish > "${fish_dir}/kerno.fish" + chmod 644 "${fish_dir}/kerno.fish" + echo " Installed to ${fish_dir}/kerno.fish" + echo " Restart fish to load the new completion" + ;; + esac +} + # ── Download ───────────────────────────────────────────────────────── get_latest_version() { curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ @@ -142,7 +198,12 @@ main() { echo "==> Installed: $(kerno version 2>/dev/null || echo "${INSTALL_DIR}/kerno")" - # ── Optional: systemd daemon ───────────────────────────────────── + # ── Optional: shell completion ──────────────────────────────────── + if [ "$INSTALL_COMPLETION" = true ]; then + install_completion + fi + + # ── Optional: systemd daemon ─────────────────────────────────────── if [ "$INSTALL_DAEMON" = true ]; then echo "" echo "==> Installing systemd service..."