diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..141f5b91 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,62 @@ +ARG TOOLS_GO_VERSION +FROM mcr.microsoft.com/devcontainers/go:dev-${TOOLS_GO_VERSION}-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + gnupg \ + gnupg2 \ + htop \ + jq \ + less \ + lsof \ + net-tools \ + openssh-client \ + unzip \ + vim \ + zsh + +ARG TARGETARCH +ARG TOOLS_HELM_VERSION +ARG TOOLS_ISTIO_VERSION +ARG TOOLS_ARGO_ROLLOUTS_VERSION +ARG TOOLS_KUBECTL_VERSION +ARG TOOLS_CILIUM_VERSION + +WORKDIR /downloads + +RUN curl -LO "https://dl.k8s.io/release/v$TOOLS_KUBECTL_VERSION/bin/linux/$TARGETARCH/kubectl" \ + && mv kubectl /usr/bin/kubectl \ + && chmod +x /usr/bin/kubectl \ + && /usr/bin/kubectl version --client + +RUN curl -L https://istio.io/downloadIstio | ISTIO_VERSION=$TOOLS_ISTIO_VERSION TARGET_ARCH=$TARGETARCH sh - \ + && mv istio-*/bin/istioctl /usr/bin/ \ + && rm -rf istio-* \ + && /usr/bin/istioctl --help + +# Install Helm +RUN curl -Lo helm.tar.gz https://get.helm.sh/helm-v${TOOLS_HELM_VERSION}-linux-${TARGETARCH}.tar.gz \ + && tar -xvf helm.tar.gz \ + && mv linux-${TARGETARCH}/helm /usr/bin/helm \ + && chmod +x /usr/bin/helm \ + && rm -rf helm.tar.gz linux-${TARGETARCH} \ + && /usr/bin/helm version + +# Install kubectl-argo-rollouts +RUN curl -Lo /usr/bin/kubectl-argo-rollouts https://github.com/argoproj/argo-rollouts/releases/download/v${TOOLS_ARGO_ROLLOUTS_VERSION}/kubectl-argo-rollouts-linux-${TARGETARCH} \ + && chmod +x /usr/bin/kubectl-argo-rollouts \ + && /usr/bin/kubectl-argo-rollouts version + +# Install Cilium CLI +RUN curl -Lo cilium.tar.gz https://github.com/cilium/cilium-cli/releases/download/v${TOOLS_CILIUM_VERSION}/cilium-linux-${TARGETARCH}.tar.gz \ + && tar -xvf cilium.tar.gz \ + && mv cilium /usr/bin/cilium \ + && chmod +x /usr/bin/cilium \ + && rm -rf cilium.tar.gz \ + && /usr/bin/cilium version + +WORKDIR /tools + +ENTRYPOINT ["zsh"] \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..f82f308d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,57 @@ +{ + "name": "kagent-tools-container", + "build": { + "dockerfile": "Dockerfile", + "args": { + "TOOLS_GO_VERSION": "1.25", + "TOOLS_ISTIO_VERSION": "1.28.3", + "TOOLS_ARGO_ROLLOUTS_VERSION": "1.8.3", + "TOOLS_KUBECTL_VERSION": "1.35.0", + "TOOLS_HELM_VERSION": "4.1.0", + "TOOLS_CILIUM_VERSION": "0.19.0" + } + }, + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, + "ghcr.io/mpriscella/features/kind:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "golang.go", + "redhat.vscode-yaml", + "ms-kubernetes-tools.vscode-kubernetes-tools", + "ms-kubernetes-tools.kind-vscode", + "dbaeumer.vscode-eslint", + "ms-azuretools.vscode-docker", + "ms-vscode.makefile-tools", + "ms-vscode.vscode-go", + "ms-python.python", + "ms-python.vscode-pylance", + "ms-toolsai.jupyter", + "ms-vscode.makefile-tools", + "ms-remote.remote-containers", + "ms-vscode.vscode-typescript-next", + "ms-azuretools.vscode-containers", + "ms-windows-ai-studio.windows-ai-studio", + "GitHub.copilot", + "GitHub.copilot-chat", + "Catppuccin.catppuccin-vsc", + "Catppuccin.catppuccin-vsc-icons" + ] + } + }, + + //user settings + "remoteUser": "root", + + //forward the following ports + "forwardPorts": [8084], + + //network + // "network": "host", + + //mount docker directly on the host + "mounts": ["source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"], + +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 845a2630..819a56eb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx @@ -27,6 +27,7 @@ jobs: - name: Run make build env: DOCKER_BUILDKIT: 1 + BUILDX_BUILDER_NAME: kagent-builder-v0.23.0 DOCKER_BUILD_ARGS: >- --cache-from=type=gha --cache-to=type=gha,mode=max @@ -41,15 +42,61 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v6 with: - go-version: "1.24" - cache: true + go-version: '^1.26.1' + cache: false - name: Run cmd/main.go tests - working-directory: go + working-directory: . run: | - go test -v ./... + make test + + go-e2e-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '^1.26.1' + cache: false + + - name: Create k8s Kind Cluster + uses: helm/kind-action@v1 + with: + cluster_name: kagent + config: scripts/kind/kind-config.yaml + + - name: Run cmd/main.go tests + working-directory: . + run: | + make e2e + + helm-unit-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Helm + uses: azure/setup-helm@v4.2.0 + with: + version: v4.1.1 + + - name: Install unittest plugin + run: | + helm plugin install --verify=false https://github.com/helm-unittest/helm-unittest + + - name: Chart init + run: | + make helm-version + + - name: Run helm unit tests + run: | + make helm-test diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index 6a479157..a1fceee3 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -33,9 +33,13 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - + with: + platforms: linux/amd64,linux/arm64 + version: v0.23.0 + use: 'true' - name: 'Build Images' env: + BUILDX_BUILDER_NAME: kagent-builder-v0.23.0 DOCKER_BUILD_ARGS: "--push --platform linux/amd64,linux/arm64" DOCKER_BUILDER: "docker buildx" run: | @@ -46,6 +50,7 @@ jobs: export VERSION=$(echo "$GITHUB_REF" | cut -c12-) fi make docker-build + make helm-publish release: # Only run release after images and helm chart are pushed # In the future we can take the chart from the helm action, diff --git a/.gitignore b/.gitignore index d146fbfc..4b3d33a1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,11 @@ bin/ .env.local .env.development.local .env.test.local -.env.production.local \ No newline at end of file +.env.production.local +/logs/ +/kagent-tools +/*.out +*.html +/helm/kagent-tools/Chart.yaml +/reports/tools-cve.csv +.dagger/ diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..4515c6a4 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +/* @EItanya @dimetron diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md new file mode 100644 index 00000000..a90f91ac --- /dev/null +++ b/CONTRIBUTION.md @@ -0,0 +1,122 @@ +# Contribution Guidelines + +## Development + +### Code of Conduct + +We are committed to providing a friendly, safe, and welcoming environment for all contributors. Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md). + +### Getting Started + +1. **Fork the repository** on GitHub. +2. **Clone your fork** locally: + ```bash + git clone https://github.com/YOUR-USERNAME/tools.git + cd kagent + ``` +3. **Add the upstream repository** as a remote: + ```bash + git remote add upstream https://github.com/kagent-dev/tools.git + ``` +4. **Create a new branch** for your changes: + ```bash + git checkout -b feature/your-feature-name + ``` + +### Development Environment Setup + +See the [DEVELOPMENT.md](DEVELOPMENT.md) file for more information. + +### Making Changes + +#### Coding Standards + +- **Go Code**: + - Follow the [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) + - Run `make lint` before submitting your changes + - Ensure all tests pass with `make test` + - Add tests for new functionality + +#### Commit Guidelines + +We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: + +- **feat**: A new feature +- **fix**: A bug fix +- **docs**: Documentation only changes +- **style**: Changes that do not affect the meaning of the code +- **refactor**: A code change that neither fixes a bug nor adds a feature +- **perf**: A code change that improves performance +- **test**: Adding missing tests or correcting existing tests +- **chore**: Changes to the build process or auxiliary tools + +Example commit message: +``` +feat(controller): add support for custom resource validation + +This adds validation for the KagentApp custom resource to ensure +that the configuration is valid before applying it to the cluster. + +Closes #123 +``` + +### Pull Request Process + +1. **Update your fork** with the latest changes from upstream: + ```bash + git fetch upstream + git rebase upstream/main + ``` + +2. **Push your changes** to your fork: + ```bash + git push origin feature/your-feature-name + ``` + +3. **Create a Pull Request** from your fork to the main repository. + +4. **Fill out the PR template** with all required information. + +5. **Address review comments** if requested by maintainers. + +6. **Update your PR** if needed: + ```bash + git add . + git commit -m "address review comments" + git push origin feature/your-feature-name + ``` + +7. Once approved, a maintainer will merge your PR. + + +### Documentation + +- Update documentation for any changes to APIs, CLIs, or user-facing features +- Add examples for new features +- Update the README if necessary +- Add comments to your code explaining complex logic + +### Releasing + +Only project maintainers can create releases. The process is: + +1. Update version numbers in relevant files +2. Create a release branch +3. Create a tag for the release +4. Build and publish artifacts +5. Create a GitHub release with release notes + +### Community + +- Join our [Discord server](https://discord.gg/Fu3k65f2k3) for discussions +- Participate in community calls (scheduled on our website) +- Help answer questions in GitHub issues +- Review pull requests from other contributors + +## License + +By contributing to this project, you agree that your contributions will be licensed under the project's license. + +## Questions? + +If you have any questions about contributing, please open an issue or reach out to the maintainers. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..4d9f5569 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,425 @@ +# Development Guide + +This document provides comprehensive development guidelines for the KAgent Tools Go project. + +## Prerequisites + +### Required Tools + +- **Go 1.24+** - Primary development language +- **Docker** - For containerization and testing +- **Make** - Build automation +- **Git** - Version control + +### Optional External Tools + +These tools enhance functionality but aren't required for basic development: + +- `kubectl` - Kubernetes CLI for k8s tools +- `helm` - Helm package manager for helm tools +- `istioctl` - Istio service mesh CLI for istio tools +- `cilium` - Cilium CLI for cilium tools + +## Project Structure + +``` +. +├── cmd/ +│ └── main.go # Entry point and MCP server +├── internal/ +│ └── version/ # Version and build metadata +├── pkg/ +│ ├── k8s/ # Kubernetes tools +│ ├── helm/ # Helm package manager tools +│ ├── istio/ # Istio service mesh tools +│ ├── cilium/ # Cilium CNI tools +│ ├── argo/ # Argo Rollouts tools +│ ├── prometheus/ # Prometheus monitoring tools +│ ├── utils/ # Common utilities +│ └── logger/ # Structured logging +├── tests/ # Integration tests +├── Makefile # Build automation +├── go.mod # Go module definition +└── go.sum # Go module checksums +``` + +## Development Workflow + +### 1. Environment Setup + +```bash +# Clone the repository +git clone +cd kagent-tools + +# Install dependencies +go mod download + +# Verify setup +go version +make help +``` + +### 2. Local Development + +```bash +# Build the project +make build + +# Run tests +make test + +# Run with verbose output +make test-verbose + +# Format code +make fmt + +# Lint code +make lint + +# Fix linting issues +make lint-fix +``` + +### 3. Running the Server + +```bash +# Build and run +make build +./bin/kagent-tools + +# Or run directly +go run ./cmd +``` + +The server starts an MCP server using SSE (Server-Sent Events) transport. + +## Code Quality Standards + +### Linting and Formatting + +```bash +# Format all Go files +go fmt ./... +make fmt + +# Run comprehensive linting +make lint + +# Fix auto-fixable lint issues +make lint-fix + +# Run go vet +make vet + +# Security vulnerability check +make govulncheck +``` + +### Testing Requirements + +- **Minimum 80% test coverage** - Use `go test -cover` to verify +- **Unit tests** for all public functions +- **Integration tests** for complex workflows +- **Table-driven tests** for multiple scenarios +- **Mock external dependencies** appropriately + +```bash +# Run tests with coverage +go test -v -cover ./... + +# Generate coverage report +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out + +# Run specific package tests +go test -v ./pkg/k8s +``` + +### Code Organization + +- **Single responsibility** - Each package has one clear purpose +- **Interface segregation** - Keep interfaces small and focused +- **Dependency injection** - Use interfaces for testability +- **Error handling** - Always handle errors explicitly +- **Context usage** - Use `context.Context` for cancellation + +## Architecture Guidelines + +### Tool Implementation Pattern + +Each tool category follows this pattern: + +```go +// pkg/[category]/[category].go +package category + +import ( + "context" + "github.com/mark3labs/mcp-go/pkg/mcp" +) + +type Tools struct { + // dependencies +} + +func NewTools() *Tools { + return &Tools{} +} + +func (t *Tools) RegisterTools(server *mcp.Server) { + server.RegisterTool("tool_name", t.handleTool) +} + +func (t *Tools) handleTool(ctx context.Context, params map[string]interface{}) (*mcp.ToolResult, error) { + // implementation +} +``` + +### Error Handling Standards + +```go +// Good: Wrap errors with context +if err != nil { + return nil, fmt.Errorf("failed to execute kubectl command: %w", err) +} + +// Good: Use custom error types when appropriate +type ValidationError struct { + Field string + Value interface{} +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("invalid %s: %v", e.Field, e.Value) +} +``` + +### Logging Standards + +```go +import "github.com/go-logr/logr" + +// Use structured logging +logger := logr.FromContextOrDiscard(ctx) +logger.Info("executing command", "command", cmd, "args", args) +logger.Error(err, "command failed", "command", cmd) +``` + +## Testing Guidelines + +### Unit Testing + +```go +func TestToolFunction(t *testing.T) { + tests := []struct { + name string + input interface{} + expected interface{} + wantErr bool + }{ + { + name: "valid input", + input: validInput, + expected: expectedOutput, + wantErr: false, + }, + { + name: "invalid input", + input: invalidInput, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ToolFunction(tt.input) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} +``` + +### Integration Testing + +```go +func TestIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + // Setup test environment + ctx := context.Background() + tools := NewTools() + + // Test actual functionality + result, err := tools.ExecuteCommand(ctx, "kubectl", []string{"version"}) + assert.NoError(t, err) + assert.Contains(t, result, "Client Version") +} +``` + +## Build and Deployment + +### Local Building + +```bash +# Build for current platform +make build + +# Build for all platforms +make build-all + +# Build specific platform +make bin/kagent-tools-linux-amd64 +``` + +### Docker + +```bash +# Build Docker image +make docker-build + +# Run in Docker +make run +``` + +## Environment Configuration + +### Environment Variables + +The application respects these environment variables: + +- `KUBECONFIG` - Kubernetes configuration file path +- `PROMETHEUS_URL` - Prometheus server URL +- `GRAFANA_URL` - Grafana server URL +- `GRAFANA_API_KEY` - Grafana API authentication key +- `LOG_LEVEL` - Logging level (debug, info, warn, error) + +### Configuration Files + +- `go.mod` - Go module dependencies +- `Makefile` - Build automation +- `.golangci.yml` - Linting configuration +- `Dockerfile` - Container build instructions + +## Debugging + +### Local Debugging + +```bash +# Run with debug logging +LOG_LEVEL=debug go run ./cmd + +# Use delve debugger +dlv debug ./cmd + +# Profile the application +go tool pprof http://localhost:6060/debug/pprof/profile +``` + +### Docker Debugging + +```bash +# Run container with debug shell +docker run -it --entrypoint /bin/sh kagent-tools:latest + +# Check container logs +docker logs +``` + +## Performance Considerations + +### Optimization Guidelines + +- **Avoid unnecessary allocations** in hot paths +- **Use connection pooling** for external services +- **Implement caching** for expensive operations +- **Use context timeouts** for external calls +- **Profile regularly** to identify bottlenecks + +### Memory Management + +```go +// Good: Reuse slices when possible +var buf []byte +if cap(buf) < needed { + buf = make([]byte, needed) +} +buf = buf[:needed] + +// Good: Use sync.Pool for expensive objects +var pool = sync.Pool{ + New: func() interface{} { + return &ExpensiveObject{} + }, +} +``` + +## Security Guidelines + +### Input Validation + +```go +// Always validate inputs +func validateInput(input string) error { + if input == "" { + return errors.New("input cannot be empty") + } + if len(input) > maxLength { + return errors.New("input too long") + } + return nil +} +``` + +### Secure Defaults + +- Use HTTPS for all external communications +- Validate all user inputs +- Handle sensitive data appropriately +- Keep dependencies updated +- Use secure random number generation + +## Contributing + +### Code Review Checklist + +- [ ] Code follows Go best practices +- [ ] Tests are included and passing +- [ ] Code coverage meets minimum requirements +- [ ] Linting passes without errors +- [ ] Documentation is updated +- [ ] Security considerations addressed +- [ ] Performance impact considered + +### Commit Guidelines + +```bash +# Format: (): +git commit -m "feat(k8s): add pod scaling functionality" +git commit -m "fix(helm): handle missing repository error" +git commit -m "docs(readme): update installation instructions" +``` + +## Troubleshooting + +### Common Issues + +1. **Build failures**: Check Go version and dependencies +2. **Test failures**: Verify external tool availability +3. **Linting errors**: Run `make lint-fix` for auto-fixes +4. **Import errors**: Run `go mod tidy` to clean dependencies + +### Getting Help + +- Check existing issues in the repository +- Review the CLAUDE.md file for project-specific guidance +- Consult Go documentation and best practices +- Ask questions in code reviews or team discussions \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0e361a12..c8c4882c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,40 +10,49 @@ RUN apk update && apk add \ && rm -rf /var/cache/apk/* ARG TARGETARCH -ARG TOOLS_HELM_VERSION -ARG TOOLS_ISTIO_VERSION -ARG TOOLS_ARGO_ROLLOUTS_VERSION -ARG TOOLS_KUBECTL_VERSION - WORKDIR /downloads +ARG TOOLS_KUBECTL_VERSION RUN curl -LO "https://dl.k8s.io/release/v$TOOLS_KUBECTL_VERSION/bin/linux/$TARGETARCH/kubectl" \ && chmod +x kubectl \ && /downloads/kubectl version --client -RUN curl -L https://istio.io/downloadIstio | ISTIO_VERSION=$TOOLS_ISTIO_VERSION TARGET_ARCH=$TARGETARCH sh - \ - && mv istio-*/bin/istioctl /downloads/ \ - && rm -rf istio-* \ - && /downloads/istioctl --help - # Install Helm +ARG TOOLS_HELM_VERSION RUN curl -Lo helm.tar.gz https://get.helm.sh/helm-v${TOOLS_HELM_VERSION}-linux-${TARGETARCH}.tar.gz \ && tar -xvf helm.tar.gz \ && mv linux-${TARGETARCH}/helm /downloads/helm \ && chmod +x /downloads/helm \ && /downloads/helm version +ARG TOOLS_ISTIO_VERSION +RUN curl -L https://istio.io/downloadIstio | ISTIO_VERSION=$TOOLS_ISTIO_VERSION TARGET_ARCH=$TARGETARCH sh - \ + && mv istio-*/bin/istioctl /downloads/ \ + && rm -rf istio-* \ + && /downloads/istioctl --help + # Install kubectl-argo-rollouts +ARG TOOLS_ARGO_ROLLOUTS_VERSION RUN curl -Lo /downloads/kubectl-argo-rollouts https://github.com/argoproj/argo-rollouts/releases/download/v${TOOLS_ARGO_ROLLOUTS_VERSION}/kubectl-argo-rollouts-linux-${TARGETARCH} \ && chmod +x /downloads/kubectl-argo-rollouts \ && /downloads/kubectl-argo-rollouts version +# Install Cilium CLI +ARG TOOLS_CILIUM_VERSION +RUN curl -Lo cilium.tar.gz https://github.com/cilium/cilium-cli/releases/download/v${TOOLS_CILIUM_VERSION}/cilium-linux-${TARGETARCH}.tar.gz \ + && tar -xvf cilium.tar.gz \ + && mv cilium /downloads/cilium \ + && chmod +x /downloads/cilium \ + && rm -rf cilium.tar.gz \ + && /downloads/cilium version + ### STAGE 2: build-tools MCP ARG BASE_IMAGE_REGISTRY=cgr.dev +ARG BUILDARCH=amd64 FROM --platform=linux/$BUILDARCH $BASE_IMAGE_REGISTRY/chainguard/go:latest AS builder - ARG TARGETPLATFORM ARG TARGETARCH +ARG BUILDARCH ARG LDFLAGS WORKDIR /workspace @@ -68,8 +77,9 @@ COPY pkg pkg # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ - --mount=type=cache,target=/root/.cache/go-build,rw \ +RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ + --mount=type=cache,target=/root/.cache/go-build,rw \ + echo "Building tool-server for $TARGETARCH on $BUILDARCH" && \ CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -ldflags "$LDFLAGS" -o tool-server cmd/main.go # Use distroless as minimal base image to package the manager binary @@ -85,6 +95,7 @@ COPY --from=tools --chown=65532:65532 /downloads/kubectl /bin/kube COPY --from=tools --chown=65532:65532 /downloads/istioctl /bin/istioctl COPY --from=tools --chown=65532:65532 /downloads/helm /bin/helm COPY --from=tools --chown=65532:65532 /downloads/kubectl-argo-rollouts /bin/kubectl-argo-rollouts +COPY --from=tools --chown=65532:65532 /downloads/cilium /bin/cilium # Copy the tool-server binary COPY --from=builder --chown=65532:65532 /workspace/tool-server /tool-server diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f7b5e615 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Solo.io, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index 86f1282b..dde2eea2 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,31 @@ DOCKER_REGISTRY ?= ghcr.io BASE_IMAGE_REGISTRY ?= cgr.dev + DOCKER_REPO ?= kagent-dev/kagent +HELM_REPO ?= oci://ghcr.io/kagent-dev +HELM_ACTION=upgrade --install + +KIND_CLUSTER_NAME ?= kagent +KIND_IMAGE_VERSION ?= 1.34.0 +KIND_CREATE_CMD ?= "kind create cluster --name $(KIND_CLUSTER_NAME) --image kindest/node:v$(KIND_IMAGE_VERSION) --config ./scripts/kind/kind-config.yaml" + BUILD_DATE := $(shell date -u '+%Y-%m-%d') GIT_COMMIT := $(shell git rev-parse --short HEAD || echo "unknown") VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/-dirty//' | grep v || echo "v0.0.0-$(GIT_COMMIT)") # Version information for the build -LDFLAGS := "-X github.com/kagent-dev/tools/internal/version.Version=$(VERSION) \ - -X github.com/kagent-dev/tools/internal/version.GitCommit=$(GIT_COMMIT) \ - -X github.com/kagent-dev/tools/internal/version.BuildDate=$(BUILD_DATE)" +LDFLAGS := -X github.com/kagent-dev/tools/internal/version.Version=$(VERSION) -X github.com/kagent-dev/tools/internal/version.GitCommit=$(GIT_COMMIT) -X github.com/kagent-dev/tools/internal/version.BuildDate=$(BUILD_DATE) + +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +PATH := $(HOME)/local/bin:/opt/homebrew/bin/:$(LOCALBIN):$(PATH) +HELM_DIST_FOLDER ?= $(shell pwd)/dist + +.PHONY: clean +clean: + rm -rf ./bin/kagent-tools-* + rm -rf $(HOME)/.local/bin/kagent-tools-* .PHONY: fmt fmt: ## Run go fmt against code. @@ -21,16 +37,36 @@ vet: ## Run go vet against code. .PHONY: lint lint: golangci-lint ## Run golangci-lint linter - $(GOLANGCI_LINT) run + $(GOLANGCI_LINT) run --build-tags=test --timeout=10m .PHONY: lint-fix lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes - $(GOLANGCI_LINT) run --fix + $(GOLANGCI_LINT) run --build-tags=test --fix --timeout=10m .PHONY: lint-config lint-config: golangci-lint ## Verify golangci-lint linter configuration $(GOLANGCI_LINT) config verify +.PHONY: govulncheck +govulncheck: + $(call go-install-tool,bin/govulncheck,golang.org/x/vuln/cmd/govulncheck,latest) + ./bin/govulncheck-latest ./... + +.PHONY: tidy +tidy: ## Run go mod tidy to ensure dependencies are up to date. + go mod tidy + +.PHONY: test +test: build lint ## Run all tests with build, lint, and coverage + go test -tags=test -v -cover ./pkg/... ./internal/... + +.PHONY: test-only +test-only: ## Run tests only (without build/lint for faster iteration) + go test -tags=test -v -cover ./pkg/... ./internal/... + +.PHONY: e2e +e2e: test retag + go test -v -tags=test -cover ./test/e2e/ -timeout 5m bin/kagent-tools-linux-amd64: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/kagent-tools-linux-amd64 ./cmd @@ -63,7 +99,24 @@ bin/kagent-tools-windows-amd64.exe.sha256: bin/kagent-tools-windows-amd64.exe sha256sum bin/kagent-tools-windows-amd64.exe > bin/kagent-tools-windows-amd64.exe.sha256 .PHONY: build -build: bin/kagent-tools-linux-amd64.sha256 bin/kagent-tools-linux-arm64.sha256 bin/kagent-tools-darwin-amd64.sha256 bin/kagent-tools-darwin-arm64.sha256 bin/kagent-tools-windows-amd64.exe.sha256 +build: $(LOCALBIN) clean bin/kagent-tools-linux-amd64.sha256 bin/kagent-tools-linux-arm64.sha256 bin/kagent-tools-darwin-amd64.sha256 bin/kagent-tools-darwin-arm64.sha256 bin/kagent-tools-windows-amd64.exe.sha256 +build: + @echo "Build complete. Binaries are available in the bin/ directory." + ls -lt bin/kagent-tools-* + +.PHONY: run +run: docker-build + @echo "Running tool server on http://localhost:8084/mcp ..." + @echo "Use: npx @modelcontextprotocol/inspector to connect to the tool server" + @docker run --rm --net=host -p 8084:8084 -e OPENAI_API_KEY=$(OPENAI_API_KEY) -v $(HOME)/.kube:/home/nonroot/.kube -e KAGENT_TOOLS_PORT=8084 $(TOOLS_IMG) -- --kubeconfig /root/.kube/config + +.PHONY: retag +retag: docker-build helm-version + @echo "Check Kind cluster $(KIND_CLUSTER_NAME) exists" + kind get clusters | grep -q $(KIND_CLUSTER_NAME) || bash -c $(KIND_CREATE_CMD) + @echo "Retagging tools image to $(RETAGGED_TOOLS_IMG)" + docker tag $(TOOLS_IMG) $(RETAGGED_TOOLS_IMG) + kind load docker-image --name $(KIND_CLUSTER_NAME) $(RETAGGED_TOOLS_IMG) TOOLS_IMAGE_NAME ?= tools TOOLS_IMAGE_TAG ?= $(VERSION) @@ -74,22 +127,176 @@ RETAGGED_TOOLS_IMG = $(RETAGGED_DOCKER_REGISTRY)/$(DOCKER_REPO)/$(TOOLS_IMAGE_NA LOCALARCH ?= $(shell uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/') -DOCKER_BUILDER ?= docker -DOCKER_BUILD_ARGS ?= --pull --load --platform linux/$(LOCALARCH) +#buildx settings +BUILDKIT_VERSION = v0.23.0 +BUILDX_NO_DEFAULT_ATTESTATIONS=1 +BUILDX_BUILDER_NAME ?= kagent-builder-$(BUILDKIT_VERSION) -TOOLS_ISTIO_VERSION ?= 1.26.1 -TOOLS_ARGO_ROLLOUTS_VERSION ?= 1.8.3 -TOOLS_KUBECTL_VERSION ?= 1.33.2 -TOOLS_HELM_VERSION ?= 3.18.3 +DOCKER_BUILDER ?= docker buildx +DOCKER_BUILD_ARGS ?= --pull --load --platform linux/$(LOCALARCH) --builder $(BUILDX_BUILDER_NAME) + +# tools image build args +TOOLS_ISTIO_VERSION ?= 1.29.1 +TOOLS_ARGO_ROLLOUTS_VERSION ?= 1.9.0 +TOOLS_KUBECTL_VERSION ?= 1.35.3 +TOOLS_HELM_VERSION ?= 4.1.3 +TOOLS_CILIUM_VERSION ?= 0.19.2 # build args TOOLS_IMAGE_BUILD_ARGS = --build-arg VERSION=$(VERSION) -TOOLS_IMAGE_BUILD_ARGS += --build-arg LDFLAGS=$(LDFLAGS) +TOOLS_IMAGE_BUILD_ARGS += --build-arg LDFLAGS="$(LDFLAGS)" +TOOLS_IMAGE_BUILD_ARGS += --build-arg LOCALARCH=$(LOCALARCH) TOOLS_IMAGE_BUILD_ARGS += --build-arg TOOLS_ISTIO_VERSION=$(TOOLS_ISTIO_VERSION) TOOLS_IMAGE_BUILD_ARGS += --build-arg TOOLS_ARGO_ROLLOUTS_VERSION=$(TOOLS_ARGO_ROLLOUTS_VERSION) TOOLS_IMAGE_BUILD_ARGS += --build-arg TOOLS_KUBECTL_VERSION=$(TOOLS_KUBECTL_VERSION) TOOLS_IMAGE_BUILD_ARGS += --build-arg TOOLS_HELM_VERSION=$(TOOLS_HELM_VERSION) +TOOLS_IMAGE_BUILD_ARGS += --build-arg TOOLS_CILIUM_VERSION=$(TOOLS_CILIUM_VERSION) + +.PHONY: buildx-create +buildx-create: + docker buildx inspect $(BUILDX_BUILDER_NAME) 2>&1 > /dev/null || \ + docker buildx create --name $(BUILDX_BUILDER_NAME) --platform linux/amd64,linux/arm64 --driver docker-container --use || true .PHONY: docker-build # build tools image -docker-build: +docker-build: fmt buildx-create + $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -t $(TOOLS_IMG) -f Dockerfile ./ + +.PHONY: docker-build # build tools image for amd64 and arm64 +docker-build-all: fmt buildx-create +docker-build-all: DOCKER_BUILD_ARGS = --progress=plain --builder $(BUILDX_BUILDER_NAME) --platform linux/amd64,linux/arm64 --output type=tar,dest=/dev/null +docker-build-all: $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) -f Dockerfile ./ + +.PHONY: helm-version +helm-version: + VERSION=$(VERSION) envsubst < helm/kagent-tools/Chart-template.yaml > helm/kagent-tools/Chart.yaml + mkdir -p $(HELM_DIST_FOLDER) + helm package -d $(HELM_DIST_FOLDER) helm/kagent-tools + +.PHONY: helm-uninstall +helm-uninstall: + helm uninstall kagent --namespace kagent --kube-context kind-$(KIND_CLUSTER_NAME) --wait + +.PHONY: helm-install +helm-install: helm-version + helm $(HELM_ACTION) kagent-tools ./helm/kagent-tools \ + --kube-context kind-$(KIND_CLUSTER_NAME) \ + --namespace kagent \ + --create-namespace \ + --history-max 2 \ + --timeout 5m \ + -f ./scripts/kind/test-values.yaml \ + --set tools.image.registry=$(RETAGGED_DOCKER_REGISTRY) \ + --wait + +.PHONY: helm-publish +helm-publish: helm-version + helm push $(HELM_DIST_FOLDER)/kagent-tools-$(VERSION).tgz $(HELM_REPO)/tools/helm + +.PHONY: helm-test +helm-test: helm-version + mkdir -p tmp + helm plugin ls | grep unittest || helm plugin install --verify=false https://github.com/helm-unittest/helm-unittest.git + helm unittest helm/kagent-tools + +.PHONY: create-kind-cluster +create-kind-cluster: + docker pull kindest/node:v$(KIND_IMAGE_VERSION) || true + bash -c $(KIND_CREATE_CMD) + +.PHONY: delete-kind-cluster +delete-kind-cluster: + kind delete cluster --name $(KIND_CLUSTER_NAME) + +.PHONY: kind-update-kagent +kind-update-kagent: retag + kubectl patch --namespace kagent deployment/kagent --type='json' -p='[{"op": "replace", "path": "/spec/template/spec/containers/3/image", "value": "$(RETAGGED_TOOLS_IMG)"}]' + +.PHONY: otel-local +otel-local: + docker rm -f jaeger-desktop || true + docker run -d --name jaeger-desktop --restart=always -p 16686:16686 -p 4317:4317 -p 4318:4318 jaegertracing/jaeger:2.7.0 + open http://localhost:16686/ + +.PHONY: tools-install +tools-install: clean + mkdir -p $(HOME)/.local/bin + go build -ldflags "$(LDFLAGS)" -o $(LOCALBIN)/kagent-tools ./cmd + go build -ldflags "$(LDFLAGS)" -o $(HOME)/.local/bin/kagent-tools ./cmd + $(HOME)/.local/bin/kagent-tools --version + +.PHONY: run-agentgateway +run-agentgateway: tools-install + open http://localhost:15000/ui + cd scripts \ + && agentgateway -f agentgateway-config-tools.yaml + +.PHONY: report/image-cve +report/image-cve: docker-build govulncheck + echo "Running CVE scan :: CVE -> CSV ... reports/$(SEMVER)/" + grype docker:$(TOOLS_IMG) -o template -t reports/cve-report.tmpl --file reports/$(SEMVER)/tools-cve.csv + +## Tool Binaries +## Location to install dependencies t + +# check-release-version checks if a tool version matches the latest GitHub release +# $1 - variable name (e.g., TOOLS_ISTIO_VERSION) +# $2 - current version value +# $3 - GitHub repo (e.g., istio/istio) +define check-release-version +@LATEST=$$(gh release list --repo $(3) --json tagName,isLatest | jq -r '.[] | select(.isLatest==true) | .tagName'); \ +if [ "$(2)" = "$${LATEST#v}" ]; then \ + echo "✅ $(1)=$(2) == $$LATEST"; \ +else \ + echo "❌ $(1)=$(2) != $$LATEST"; \ +fi +endef + +define check-go-version +@CURRENT_GO=$$(awk '/^go / { print $$2 }' go.mod); \ +LATEST_GO=$$(curl -ks 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1 | sed 's/^go//' || echo "unknown"); \ +if [ "$$CURRENT_GO" = "$$LATEST_GO" ]; then \ + echo "✅ GO_VERSION=$$CURRENT_GO == $$LATEST_GO"; \ +else \ + echo "❌ GO_VERSION=$$CURRENT_GO != $$LATEST_GO"; \ +fi +endef + +.PHONY: check-releases +check-releases: + @echo "Checking tool versions against latest releases..." + @echo "" + $(call check-go-version) + $(call check-release-version,TOOLS_ARGO_ROLLOUTS_VERSION,$(TOOLS_ARGO_ROLLOUTS_VERSION),argoproj/argo-rollouts) + $(call check-release-version,TOOLS_CILIUM_VERSION,$(TOOLS_CILIUM_VERSION),cilium/cilium-cli) + $(call check-release-version,TOOLS_ISTIO_VERSION,$(TOOLS_ISTIO_VERSION),istio/istio) + $(call check-release-version,TOOLS_HELM_VERSION,$(TOOLS_HELM_VERSION),helm/helm) + $(call check-release-version,TOOLS_KUBECTL_VERSION,$(TOOLS_KUBECTL_VERSION),kubernetes/kubernetes) + +.PHONY: $(LOCALBIN) +$(LOCALBIN): + mkdir -p $(LOCALBIN) + +GOLANGCI_LINT = $(LOCALBIN)/golangci-lint +GOLANGCI_LINT_VERSION ?= v1.63.4 + +.PHONY: golangci-lint +golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. +$(GOLANGCI_LINT): $(LOCALBIN) + $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) + +# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist +# $1 - target path with name of binary +# $2 - package url which can be installed +# $3 - specific version of package +define go-install-tool +@[ -f "$(1)-$(3)" ] || { \ +set -e; \ +package=$(2)@$(3) ;\ +echo "Downloading $${package}" ;\ +rm -f $(1) || true ;\ +GOBIN=$(LOCALBIN) go install $${package} ;\ +mv $(1) $(1)-$(3) ;\ +} ;\ +ln -sf $(1)-$(3) $(1) +endef \ No newline at end of file diff --git a/README.md b/README.md index 3139f0d3..4b1c5185 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,54 @@ + + +--- + # KAgent Tools - Go Implementation This directory contains the Go implementation of all KAgent tools, migrated from the original Python implementation. The tools are designed to work with the Model Context Protocol (MCP) server and provide comprehensive Kubernetes, cloud-native, and observability functionality. +## Installation + +- **Bash:** + +```bash +curl -sL https://raw.githubusercontent.com/kagent-dev/tools/refs/heads/main/scripts/install.sh | bash +``` + +- **Docker:** + +```bash +docker run -it --rm -p 8084:8084 ghcr.io/kagent-dev/kagent/tools:0.0.13 +``` + +- **Kubernetes** + +```bash +helm upgrade -i -n kagent --create-namespace kagent-tools oci://ghcr.io/kagent-dev/tools/helm/kagent-tools --version 0.0.13 +helm ls -A +``` + +## Quickstart Guide + +For a quickstart guide on how to run KAgent tools using AgentGateway, please refer to the [Quickstart Guide](docs/quickstart.md). + ## Architecture The Go tools are implemented as a single MCP server that exposes all available tools through the MCP protocol. @@ -141,9 +188,21 @@ go build -o kagent-tools . The server runs using sse transport for MCP communication. +#### CLI Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--port`, `-p` | `8084` | Port to run the MCP server on | +| `--metrics-port` | `8084` | Port to run the Prometheus metrics server on | +| `--stdio` | `false` | Use stdio for communication instead of HTTP | +| `--tools` | `[]` (all) | Comma-separated list of tool providers to register | +| `--read-only` | `false` | Disable tools that perform write operations | +| `--kubeconfig` | `""` | Path to kubeconfig file (defaults to in-cluster config) | +| `--version`, `-v` | `false` | Show version information and exit | + ### Testing ```bash -go test -v +go test -v ./... ``` ## Tool Implementation Details @@ -196,6 +255,25 @@ Tools can be configured through environment variables: - `GRAFANA_URL`: Default Grafana server URL - `GRAFANA_API_KEY`: Default Grafana API key +## Observability + +The MCP server exposes Prometheus metrics on a configurable HTTP endpoint (`/metrics`). By default, the metrics endpoint runs on the same port as the MCP server. To run it on a separate port: + +```bash +./kagent-tools --port 8084 --metrics-port 9090 +``` + +### Exposed Metrics + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `kagent_tools_mcp_server_info` | Gauge | `server_name`, `version`, `git_commit`, `build_date`, `server_mode` | Server metadata (always set to 1) | +| `kagent_tools_mcp_registered_tools` | Gauge | `tool_name`, `tool_provider` | Set to 1 for each registered tool | +| `kagent_tools_mcp_invocations_total` | Counter | `tool_name`, `tool_provider` | Total number of tool invocations | +| `kagent_tools_mcp_invocations_failure_total` | Counter | `tool_name`, `tool_provider` | Total number of failed tool invocations | + +Standard Go runtime and process metrics are also included (goroutines, memory, CPU, file descriptors, etc.). + ## Error Handling and Debugging The tools provide detailed error messages and support verbose output. When debugging issues: @@ -211,9 +289,8 @@ Potential areas for future improvement: 1. **Native Client Libraries**: Replace CLI calls with native Go client libraries where possible 2. **Advanced Documentation Search**: Implement full vector search for documentation queries 3. **Caching**: Add caching for frequently accessed data -4. **Metrics and Observability**: Add metrics and tracing for tool usage -5. **Configuration Management**: Enhanced configuration management and validation -6. **Parallel Execution**: Support for parallel execution of related operations +4. **Configuration Management**: Enhanced configuration management and validation +5. **Parallel Execution**: Support for parallel execution of related operations ## Contributing diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..a8d71206 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +## Security vulnerabilities + +Review how the kagent project handles the lifecycle of Common Vulnerability and Exposures (CVEs). + +### 📨 Where to report + +To report a security vulnerability, email the private Google group kagent-vulnerability-reports@googlegroups.com. + +### ✅ When to send a report + +Send a report when: + + You discover that a kagent component has a potential security vulnerability. + You are unsure whether or how a vulnerability affects kagent. + +### 🔔 Check before sending + +If in doubt, send a private message about potential vulnerabilities such as: + + Any crash, especially in kagent. + Any potential Denial of Service (DoS) attack. + +### ❌ When NOT to send a report + +Do not send a report for vulnerabilities that are not part of the kagent project, such as: + + You want help configuring kagent components for security purposes. + You want help applying security related updates to your kagent configuration or environment. + Your issue is not related to security vulnerabilities. + Your issue is related to base image dependencies, such as AutoGen. + +### Evaluation + +The kagent team evaluates vulnerability reports for: + + Severity level, which can affect the priority of the fix + Impact of the vulnerability on kagent code as opposed to backend code + Potential dependencies on third-party or backend code that might delay the remediation process + +The kagent team strives to keep private any vulnerability information with us as part of the remediation process. We only share information on a need-to-know basis to address the issue. diff --git a/cmd/main.go b/cmd/main.go index 2267733b..943b7db2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,31 +7,44 @@ import ( "net/http" "os" "os/signal" + "runtime" + "strconv" "strings" "sync" "syscall" "time" - "github.com/kagent-dev/tools/pkg/utils" - + "github.com/joho/godotenv" + "github.com/kagent-dev/tools/internal/logger" + "github.com/kagent-dev/tools/internal/metrics" + "github.com/kagent-dev/tools/internal/telemetry" "github.com/kagent-dev/tools/internal/version" - "github.com/kagent-dev/tools/pkg/logger" - "github.com/kagent-dev/tools/pkg/argo" "github.com/kagent-dev/tools/pkg/cilium" "github.com/kagent-dev/tools/pkg/helm" "github.com/kagent-dev/tools/pkg/istio" "github.com/kagent-dev/tools/pkg/k8s" + "github.com/kagent-dev/tools/pkg/kubescape" "github.com/kagent-dev/tools/pkg/prometheus" - "github.com/mark3labs/mcp-go/server" - "github.com/mark3labs/mcp-go/util" + "github.com/kagent-dev/tools/pkg/utils" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/spf13/cobra" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" ) var ( - port int - stdio bool - tools []string + port int + metricsPort int + stdio bool + tools []string + kubeconfig *string + showVersion bool + readOnly bool // These variables should be set during build time using -ldflags Name = "kagent-tools-server" @@ -48,8 +61,17 @@ var rootCmd = &cobra.Command{ func init() { rootCmd.Flags().IntVarP(&port, "port", "p", 8084, "Port to run the server on") + rootCmd.Flags().IntVarP(&metricsPort, "metrics-port", "m", 0, "Port to run the metrics server on (default 0: same as --port)") rootCmd.Flags().BoolVar(&stdio, "stdio", false, "Use stdio for communication instead of HTTP") rootCmd.Flags().StringSliceVar(&tools, "tools", []string{}, "List of tools to register. If empty, all tools are registered.") + rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information and exit") + rootCmd.Flags().BoolVar(&readOnly, "read-only", false, "Run in read-only mode (disable tools that perform write operations)") + kubeconfig = rootCmd.Flags().String("kubeconfig", "", "kubeconfig file path (optional, defaults to in-cluster config)") + + // if found .env file, load it + if _, err := os.Stat(".env"); err == nil { + _ = godotenv.Load(".env") + } } func main() { @@ -59,23 +81,75 @@ func main() { } } +// printVersion displays version information in a formatted way +func printVersion() { + fmt.Printf("%s\n", Name) + fmt.Printf("Version: %s\n", Version) + fmt.Printf("Git Commit: %s\n", GitCommit) + fmt.Printf("Build Date: %s\n", BuildDate) + fmt.Printf("Go Version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) +} + func run(cmd *cobra.Command, args []string) { - logger.Init() - defer logger.Sync() + // Handle version flag early, before any initialization + if showVersion { + printVersion() + return + } - logger.Get().Info("Starting "+Name, "version", Version, "git_commit", GitCommit, "build_date", BuildDate) + // 0 means "same as --port" - resolve it before any server logic uses it + if metricsPort == 0 { + metricsPort = port + } + + logger.Init(stdio) + defer logger.Sync() // Setup context with cancellation for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Initialize OpenTelemetry tracing + cfg := telemetry.LoadOtelCfg() + + err := telemetry.SetupOTelSDK(ctx) + if err != nil { + logger.Get().Error("Failed to setup OpenTelemetry SDK", "error", err) + os.Exit(1) + } + + // Start root span for server lifecycle + tracer := otel.Tracer("kagent-tools/server") + ctx, rootSpan := tracer.Start(ctx, "server.lifecycle") + defer rootSpan.End() + + rootSpan.SetAttributes( + attribute.String("server.name", Name), + attribute.String("server.version", cfg.Telemetry.ServiceVersion), + attribute.String("server.git_commit", GitCommit), + attribute.String("server.build_date", BuildDate), + attribute.Bool("server.stdio_mode", stdio), + attribute.Int("server.port", port), + attribute.StringSlice("server.tools", tools), + attribute.Bool("server.read_only", readOnly), + ) + + logger.Get().Info("Starting "+Name, "version", Version, "git_commit", GitCommit, "build_date", BuildDate) + if readOnly { + logger.Get().Info("Running in read-only mode - write operations are disabled") + } + mcp := server.NewMCPServer( Name, Version, ) - // Register tools - registerMCP(mcp, tools) + // Register tools and wrap handlers with metrics instrumentation. + // registerMCP returns a map of tool_name -> tool_provider so that + // wrapToolHandlersWithMetrics knows which provider each tool belongs to. + toolProviders := registerMCP(mcp, tools, *kubeconfig, readOnly) + wrapToolHandlersWithMetrics(mcp, toolProviders) // Create wait group for server goroutines var wg sync.WaitGroup @@ -85,7 +159,8 @@ func run(cmd *cobra.Command, args []string) { signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) // HTTP server reference (only used when not in stdio mode) - var sseServer *server.StreamableHTTPServer + var httpServer *http.Server + var metricsServer *http.Server // Separate server for metrics if metricsPort is different from main port // Start server based on chosen mode wg.Add(1) @@ -95,16 +170,74 @@ func run(cmd *cobra.Command, args []string) { runStdioServer(ctx, mcp) }() } else { - sseServer = server.NewStreamableHTTPServer(mcp, server.WithLogger(util.DefaultLogger()), server.WithStateLess(true)) + sseServer := server.NewStreamableHTTPServer(mcp, + server.WithHeartbeatInterval(30*time.Second), + ) + + // Create a mux to handle different routes + mux := http.NewServeMux() + + // Add health endpoint + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if err := writeResponse(w, []byte("OK")); err != nil { + logger.Get().Error("Failed to write health response", "error", err) + } + }) + + // Add metrics endpoint + registry := metrics.InitServer() // Initialize Prometheus metrics before starting the server + + if metricsPort != port { // Only start a separate metrics server if the metrics port is different from the main server port + // Create the metrics server outside the goroutine to avoid a race condition + // between the goroutine assigning metricsServer and the shutdown handler reading it + metricsMux := http.NewServeMux() + metricsMux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) + metricsServer = &http.Server{ + Addr: fmt.Sprintf(":%d", metricsPort), + Handler: metricsMux, + } + + wg.Add(1) + go func() { + defer wg.Done() + logger.Get().Info("Starting Prometheus metrics endpoint on /metrics", "port", strconv.Itoa(metricsPort)) + if err := metricsServer.ListenAndServe(); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + logger.Get().Error("Metrics endpoint failed", "error", err) + } else { + logger.Get().Info("Metrics server closed gracefully.") + } + } + }() + } else { + logger.Get().Info("Starting Prometheus metrics endpoint on /metrics", "port", strconv.Itoa(port)) + mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) + } + serverMode := "read-write" + if readOnly { + serverMode = "read-only" + } + metrics.KagentToolsMCPServerInfo.WithLabelValues(Name, Version, GitCommit, BuildDate, serverMode).Set(1) + + // Handle all other routes with the MCP server wrapped in telemetry middleware + mux.Handle("/", telemetry.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sseServer.ServeHTTP(w, r) + }))) + + httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + } + go func() { defer wg.Done() - addr := fmt.Sprintf(":%d", port) - logger.Get().Info("Running KAgent Tools Server", "port", addr, "tools", strings.Join(tools, ",")) - if err := sseServer.Start(addr); err != nil { + logger.Get().Info("Running KAgent Tools Server", "port", fmt.Sprintf(":%d", port), "tools", strings.Join(tools, ",")) + if err := httpServer.ListenAndServe(); err != nil { if !errors.Is(err, http.ErrServerClosed) { - logger.Get().Error(err, "Failed to start SSE server") + logger.Get().Error("Failed to start HTTP server", "error", err) } else { - logger.Get().Info("SSE server closed gracefully.") + logger.Get().Info("HTTP server closed gracefully.") } } }() @@ -115,16 +248,36 @@ func run(cmd *cobra.Command, args []string) { <-signalChan logger.Get().Info("Received termination signal, shutting down server...") + // Mark root span as shutting down + rootSpan.AddEvent("server.shutdown.initiated") + // Cancel context to notify any context-aware operations cancel() // Gracefully shutdown HTTP server if running - if !stdio && sseServer != nil { + if !stdio && httpServer != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + logger.Get().Error("Failed to shutdown server gracefully", "error", err) + rootSpan.RecordError(err) + rootSpan.SetStatus(codes.Error, "Server shutdown failed") + } else { + rootSpan.AddEvent("server.shutdown.completed") + } + } + + // Gracefully shutdown metrics server if running separately + if !stdio && metricsServer != nil { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() - if err := sseServer.Shutdown(shutdownCtx); err != nil { - logger.Get().Error(err, "Failed to shutdown server gracefully") + if err := metricsServer.Shutdown(shutdownCtx); err != nil { + logger.Get().Error("Failed to shutdown metrics server gracefully", "error", err) + rootSpan.RecordError(err) + } else { + logger.Get().Info("Metrics server shutdown completed") } } }() @@ -134,6 +287,12 @@ func run(cmd *cobra.Command, args []string) { logger.Get().Info("Server shutdown complete") } +// writeResponse writes data to an HTTP response writer with proper error handling +func writeResponse(w http.ResponseWriter, data []byte) error { + _, err := w.Write(data) + return err +} + func runStdioServer(ctx context.Context, mcp *server.MCPServer) { logger.Get().Info("Running KAgent Tools Server STDIO:", "tools", strings.Join(tools, ",")) stdioServer := server.NewStdioServer(mcp) @@ -142,36 +301,106 @@ func runStdioServer(ctx context.Context, mcp *server.MCPServer) { } } -func registerMCP(mcp *server.MCPServer, enabledToolProviders []string) { - - var toolProviderMap = map[string]func(*server.MCPServer){ - "utils": utils.RegisterDateTimeTools, - "k8s": k8s.RegisterK8sTools, - "prometheus": prometheus.RegisterPrometheusTools, - "helm": helm.RegisterHelmTools, - "istio": istio.RegisterIstioTools, - "argo": argo.RegisterArgoTools, - "cilium": cilium.RegisterCiliumTools, +// registerMCP registers tool providers with the MCP server and returns a mapping +// of tool_name -> tool_provider. This mapping is built using the ListTools() diff +// technique: we snapshot the tool list before and after each provider registers, +// so we know exactly which tools belong to which provider. +func registerMCP(mcp *server.MCPServer, enabledToolProviders []string, kubeconfig string, readOnly bool) map[string]string { + // A map to hold tool providers and their registration functions + toolProviderMap := map[string]func(*server.MCPServer){ + "argo": func(s *server.MCPServer) { argo.RegisterTools(s, readOnly) }, + "cilium": func(s *server.MCPServer) { cilium.RegisterTools(s, readOnly) }, + "helm": func(s *server.MCPServer) { helm.RegisterTools(s, readOnly) }, + "istio": func(s *server.MCPServer) { istio.RegisterTools(s, readOnly) }, + "k8s": func(s *server.MCPServer) { k8s.RegisterTools(s, nil, kubeconfig, readOnly) }, + "kubescape": func(s *server.MCPServer) { kubescape.RegisterTools(s, kubeconfig, readOnly) }, + "prometheus": func(s *server.MCPServer) { prometheus.RegisterTools(s, readOnly) }, + "utils": func(s *server.MCPServer) { utils.RegisterTools(s, readOnly) }, } - // If no tools specified, register all tools + // If no specific tools are specified, register all available tools. if len(enabledToolProviders) == 0 { - logger.Get().Info("No specific tools provided, registering all tools") - for toolProvider, registerFunc := range toolProviderMap { - logger.Get().Info("Registering tools", "provider", toolProvider) - registerFunc(mcp) + for name := range toolProviderMap { + enabledToolProviders = append(enabledToolProviders, name) } - return } - // Register only the specified tools - logger.Get().Info("provider list", "tools", enabledToolProviders) + // toolToProvider maps each tool name to its provider (e.g., "kubectl_get" -> "k8s"). + // This is used later by wrapToolHandlersWithMetrics to set the correct tool_provider label. + toolToProvider := make(map[string]string) + for _, toolProviderName := range enabledToolProviders { - if registerFunc, ok := toolProviderMap[strings.ToLower(toolProviderName)]; ok { - logger.Get().Info("Registering tool", "provider", toolProviderName) + if registerFunc, ok := toolProviderMap[toolProviderName]; ok { + // Snapshot the tool list before this provider registers its tools. + // We need this because ListTools() returns ALL tools from ALL providers, + // so the only way to know which tools belong to THIS provider is to compare + // the list before and after registration. + toolsBefore := mcp.ListTools() + registerFunc(mcp) + + // Determine which tools were just registered by this provider + // by finding tools that exist now but didn't exist before. + // Record each one in Prometheus so we can observe the full tool inventory. + for toolName := range mcp.ListTools() { + if _, existed := toolsBefore[toolName]; !existed { + metrics.KagentToolsMCPRegisteredTools.WithLabelValues(toolName, toolProviderName).Set(1) + toolToProvider[toolName] = toolProviderName + } + } } else { - logger.Get().Error(nil, "Unknown tool specified", "provider", toolProviderName) + logger.Get().Error("Unknown tool specified", "provider", toolProviderName) } } + + return toolToProvider +} + +// wrapToolHandlersWithMetrics applies the wrapper/middleware pattern to instrument +// all registered MCP tool handlers with Prometheus invocation counters. +// +// How it works: +// 1. Grab all registered tools from the MCP server using ListTools() +// 2. For each tool, wrap its handler with a function that increments metrics +// 3. Replace all tools in the MCP server using SetTools() +// +// The wrapper function: +// - Increments kagent_tools_mcp_invocations_total on every call +// - Increments kagent_tools_mcp_invocations_failure_total when the handler returns a +// non-nil Go error OR when result.IsError is true (the MCP convention for tool-level +// failures - handlers return NewToolResultError(...), nil, not a Go error) +// - Calls the original handler unchanged - the tool's behaviour is not affected +// +// This uses the standard middleware/decorator pattern: the original handler and the +// wrapped handler have the same function signature, so they are interchangeable. +// No changes are required in any pkg/ file - all instrumentation happens centrally here. +func wrapToolHandlersWithMetrics(mcpServer *server.MCPServer, toolToProvider map[string]string) { + allTools := mcpServer.ListTools() + wrapped := make([]server.ServerTool, 0, len(allTools)) + + for name, st := range allTools { + originalHandler := st.Handler + toolName := name // capture for closure + provider := toolToProvider[toolName] + + wrapped = append(wrapped, server.ServerTool{ + Tool: st.Tool, + Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + metrics.KagentToolsMCPInvocationsTotal.WithLabelValues(toolName, provider).Inc() + + result, err := originalHandler(ctx, req) + + // Count as failure if the Go error is non-nil OR if the tool returned + // a result with IsError=true (the MCP convention for tool-level failures, + // which always return nil for the Go error). + if err != nil || (result != nil && result.IsError) { + metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues(toolName, provider).Inc() + } + + return result, err + }, + }) + } + + mcpServer.SetTools(wrapped...) } diff --git a/cmd/metrics_wrap_test.go b/cmd/metrics_wrap_test.go new file mode 100644 index 00000000..0b8ca730 --- /dev/null +++ b/cmd/metrics_wrap_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "fmt" + "testing" + + "github.com/kagent-dev/tools/internal/metrics" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + promtest "github.com/prometheus/client_golang/prometheus/testutil" +) + +// newTestServer creates a fresh MCP server and resets the metric counters so +// tests do not interfere with each other. +func newTestServer() *server.MCPServer { + metrics.KagentToolsMCPInvocationsTotal.Reset() + metrics.KagentToolsMCPInvocationsFailureTotal.Reset() + return server.NewMCPServer("test-server", "test") +} + +// invokeWrapped registers handler on s, wraps all handlers with metrics, then +// calls the wrapped handler for toolName and returns its result. +func invokeWrapped(t *testing.T, s *server.MCPServer, toolName string, provider string, handler server.ToolHandlerFunc) (*mcp.CallToolResult, error) { + t.Helper() + s.AddTool(mcp.Tool{Name: toolName}, handler) + wrapToolHandlersWithMetrics(s, map[string]string{toolName: provider}) + st, ok := s.ListTools()[toolName] + if !ok { + t.Fatalf("tool %q not found after wrapping", toolName) + } + return st.Handler(context.Background(), mcp.CallToolRequest{}) +} + +// TestWrapToolHandlersWithMetrics_IsErrorIncrementsFailureCounter is the +// critical regression test for the bug identified in PR review: +// +// Handlers signal tool-level failures via NewToolResultError(...), nil +// (result.IsError=true, Go error=nil), so checking only `err != nil` would +// never count these as failures. +// +// To replicate manually: +// +// go test -v -run TestWrapToolHandlersWithMetrics_IsErrorIncrementsFailureCounter ./cmd/ +func TestWrapToolHandlersWithMetrics_IsErrorIncrementsFailureCounter(t *testing.T) { + s := newTestServer() + + result, err := invokeWrapped(t, s, "failing_tool", "test", + func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // This is the pattern used 214 times across pkg/ - returns a tool-level + // error with IsError=true but a nil Go error. + return mcp.NewToolResultError("kubectl: resource not found"), nil + }, + ) + + if err != nil { + t.Fatalf("expected nil Go error from handler, got: %v", err) + } + if !result.IsError { + t.Fatal("expected result.IsError=true") + } + + total := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsTotal.WithLabelValues("failing_tool", "test")) + if total != 1 { + t.Errorf("invocations_total: expected 1, got %v", total) + } + + failures := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues("failing_tool", "test")) + if failures != 1 { + t.Errorf("invocations_failure_total: expected 1, got %v (IsError=true was not counted as failure)", failures) + } +} + +// TestWrapToolHandlersWithMetrics_SuccessDoesNotIncrementFailureCounter verifies +// that a successful tool call does not touch the failure counter. +// +// To replicate manually: +// +// go test -v -run TestWrapToolHandlersWithMetrics_SuccessDoesNotIncrementFailureCounter ./cmd/ +func TestWrapToolHandlersWithMetrics_SuccessDoesNotIncrementFailureCounter(t *testing.T) { + s := newTestServer() + + _, err := invokeWrapped(t, s, "success_tool", "test", + func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText("all good"), nil + }, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + total := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsTotal.WithLabelValues("success_tool", "test")) + if total != 1 { + t.Errorf("invocations_total: expected 1, got %v", total) + } + + failures := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues("success_tool", "test")) + if failures != 0 { + t.Errorf("invocations_failure_total: expected 0 for a successful call, got %v", failures) + } +} + +// TestWrapToolHandlersWithMetrics_GoErrorIncrementsFailureCounter verifies +// that a real Go error (e.g. infrastructure failure) is also counted. +// +// To replicate manually: +// +// go test -v -run TestWrapToolHandlersWithMetrics_GoErrorIncrementsFailureCounter ./cmd/ +func TestWrapToolHandlersWithMetrics_GoErrorIncrementsFailureCounter(t *testing.T) { + s := newTestServer() + + _, err := invokeWrapped(t, s, "broken_tool", "test", + func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, fmt.Errorf("connection refused") + }, + ) + + if err == nil { + t.Fatal("expected a Go error, got nil") + } + + failures := promtest.ToFloat64(metrics.KagentToolsMCPInvocationsFailureTotal.WithLabelValues("broken_tool", "test")) + if failures != 1 { + t.Errorf("invocations_failure_total: expected 1 for Go error, got %v", failures) + } +} diff --git a/dashboard/grafana-dash-example.png b/dashboard/grafana-dash-example.png new file mode 100644 index 00000000..6ffe3117 Binary files /dev/null and b/dashboard/grafana-dash-example.png differ diff --git a/dashboard/grafana-dashboard.json b/dashboard/grafana-dashboard.json new file mode 100644 index 00000000..801a052a --- /dev/null +++ b/dashboard/grafana-dashboard.json @@ -0,0 +1,819 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": 29, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "/^version$/", + "values": false + }, + "showPercentChange": false, + "text": {}, + "textMode": "value", + "wideLayout": true + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "kagent_tools_mcp_server_info", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "A" + } + ], + "title": "Server Version", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "count(kagent_tools_mcp_registered_tools)", + "instant": true, + "legendFormat": "Registered Tools", + "range": false, + "refId": "A" + } + ], + "title": "Total Registered Tools", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "yellow", + "value": 10 + }, + { + "color": "red", + "value": 100 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(increase(kagent_tools_mcp_invocations_total[5m]))", + "instant": true, + "legendFormat": "Total Invocations (5m)", + "range": false, + "refId": "A" + } + ], + "title": "Invocations (Last 5m)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "yellow", + "value": 95 + }, + { + "color": "green", + "value": 99 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "100 - (sum(rate(kagent_tools_mcp_invocations_failure_total[5m])) / sum(rate(kagent_tools_mcp_invocations_total[5m])) * 100)", + "instant": true, + "legendFormat": "Success Rate", + "range": false, + "refId": "A" + } + ], + "title": "Success Rate", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 4 + }, + "id": 5, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(kagent_tools_mcp_invocations_total[$__rate_interval])) by (tool_provider)", + "legendFormat": "{{tool_provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Invocation Rate by Provider", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failures" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 4 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(kagent_tools_mcp_invocations_total[$__rate_interval]))", + "legendFormat": "Total", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(kagent_tools_mcp_invocations_failure_total[$__rate_interval]))", + "hide": false, + "legendFormat": "Failures", + "range": true, + "refId": "B" + } + ], + "title": "Total Invocations vs Failures", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 12 + }, + "id": 7, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "sort": "desc", + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum by(tool_provider) (kagent_tools_mcp_registered_tools)", + "legendFormat": "{{tool_provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Tools by Provider", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "footer": { + "reducers": [] + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Invocations" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + }, + { + "id": "color", + "value": { + "mode": "continuous-GrYlRd" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failures" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "thresholds" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 10 + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 16, + "x": 8, + "y": 12 + }, + "id": 8, + "options": { + "cellHeight": "sm", + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Invocations" + } + ] + }, + "pluginVersion": "12.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by(tool_name, tool_provider) (kagent_tools_mcp_invocations_total)", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by(tool_name, tool_provider) (kagent_tools_mcp_invocations_failure_total)", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "B" + } + ], + "title": "Top Invoked Tools", + "transformations": [ + { + "id": "seriesToColumns", + "options": { + "byField": "tool_name" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "Time 1": true, + "Time 2": true, + "tool_provider 2": true + }, + "includeByName": {}, + "indexByName": { + "Time 1": 4, + "Time 2": 5, + "Value #A": 2, + "Value #B": 3, + "tool_name": 0, + "tool_provider 1": 1, + "tool_provider 2": 6 + }, + "renameByName": { + "Value #A": "Invocations", + "Value #B": "Failures", + "tool_name": "Tool Name", + "tool_provider 1": "Provider" + } + } + } + ], + "type": "table" + } + ], + "preload": false, + "refresh": "30s", + "schemaVersion": 42, + "tags": [ + "kagent", + "mcp", + "tools" + ], + "templating": { + "list": [ + { + "current": { + "text": "Prometheus", + "value": "prometheus" + }, + "includeAll": false, + "label": "Datasource", + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "kAgent Tools - MCP Observability", + "uid": "kagent-tools-mcp", + "version": 1 +} \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 00000000..85b86cc8 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,78 @@ + +# Quickstart Guide for KAgnet Tools + +## About this guide + +This guide provides a quick overview of how to set up and run KAgent tools using AgentGateway. + +For more detaled information on KAgent tools, please refer to the [KAgent Tools Documentation](https://kagent.dev/tools). + +To learn more about agentgateway, see [AgentGateway](https://agentgateway.dev/docs/about/) + +### Running KAgent Tools using AgentGateway + +1. Download tools binary and install it. +2. Download tools configuration file for agentgateway. +3. Download the agentgateway binary and install it. +4. Run the agentgateway with the configuration file. +5. open http://localhost:15000/ui + +```bash +curl -sL https://raw.githubusercontent.com/kagent-dev/tools/refs/heads/main/scripts/install.sh | bash +curl -sL https://raw.githubusercontent.com/kagent-dev/tools/refs/heads/main/scripts/agentgateway-config-tools.yaml +curl -sL https://raw.githubusercontent.com/agentgateway/agentgateway/refs/heads/main/common/scripts/get-agentproxy | bash + +export PATH=$PATH:$HOME/.local/bin/ +agentgateway -f agentgateway-config-tools.yaml +``` + +agentgateway-config-tools.yaml: +```yaml +binds: + - port: 30805 + listeners: + - routes: + - policies: + cors: + allowOrigins: + - "*" + allowHeaders: + - mcp-protocol-version + - content-type + backends: + - mcp: + name: default + targets: + - name: kagent-tools + stdio: + cmd: kagent-tools + args: ["--stdio", "--kubeconfig", "~/.kube/config"] +``` +Afterwards, you can run it with make command +```bash +make run-agentgateway +``` + +### Running KAgent Tools using Cursor MCP + + +1. Download the agentgateway binary and install it. +``` +curl -sL https://raw.githubusercontent.com/kagent-dev/tools/refs/heads/main/scripts/install.sh | bash +``` + +2. Create `.cursor/mcp.json` + +```json +{ + "mcpServers": { + "kagent-tools": { + "command": "kagent-tools", + "args": ["--stdio", "--kubeconfig", "~/.kube/config"] + } + } +} +``` + + + diff --git a/go.mod b/go.mod index fa06ede2..5f1e7439 100644 --- a/go.mod +++ b/go.mod @@ -1,64 +1,205 @@ module github.com/kagent-dev/tools -go 1.24.1 +go 1.26.1 require ( - github.com/go-logr/logr v1.4.3 - github.com/go-logr/stdr v1.2.2 - github.com/mark3labs/mcp-go v0.32.0 - github.com/spf13/cobra v1.9.1 - github.com/stretchr/testify v1.10.0 - github.com/tmc/langchaingo v0.1.13 - go.opentelemetry.io/otel v1.37.0 - go.opentelemetry.io/otel/metric v1.37.0 - k8s.io/api v0.33.2 - k8s.io/apimachinery v0.33.2 - k8s.io/client-go v0.33.2 - k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 + github.com/joho/godotenv v1.5.1 + github.com/kubescape/k8s-interface v0.0.203 + github.com/kubescape/storage v0.0.239 + github.com/mark3labs/mcp-go v0.43.2 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + github.com/tmc/langchaingo v0.1.14 + go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 + go.opentelemetry.io/otel/metric v1.40.0 + go.opentelemetry.io/otel/sdk v1.40.0 + go.opentelemetry.io/otel/trace v1.40.0 + k8s.io/api v0.35.1 + k8s.io/apiextensions-apiserver v0.35.1 + k8s.io/apimachinery v0.35.1 + k8s.io/client-go v0.35.1 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dlclark/regexp2 v1.10.0 // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/gnostic-models v0.6.9 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/acobaugh/osrelease v0.1.0 // indirect + github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 // indirect + github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 // indirect + github.com/anchore/stereoscope v0.1.9 // indirect + github.com/anchore/syft v1.32.0 // indirect + github.com/armosec/armoapi-go v0.0.674 // indirect + github.com/armosec/gojay v1.2.17 // indirect + github.com/armosec/utils-go v0.0.58 // indirect + github.com/armosec/utils-k8s-go v0.0.35 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/becheran/wildmatch-go v1.0.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/briandowns/spinner v1.23.2 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cilium/cilium v1.19.0 // indirect + github.com/cilium/ebpf v0.20.1-0.20260108141042-f7e80f49188b // indirect + github.com/cilium/hive v0.0.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containers/common v0.63.0 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/docker/cli v28.3.3+incompatible // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/facebookincubator/nvdtools v0.1.5 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/francoispqt/gojay v1.2.13 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/github/go-spdx/v2 v2.3.3 // indirect + github.com/gkampitakis/go-snaps v0.5.19 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.24.2 // indirect + github.com/go-openapi/errors v0.22.6 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/loads v0.23.2 // indirect + github.com/go-openapi/spec v0.22.3 // indirect + github.com/go-openapi/strfmt v0.25.0 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-openapi/validate v0.25.1 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gohugoio/hashstructure v0.5.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-containerregistry v0.20.6 // indirect + github.com/google/licensecheck v0.3.1 // indirect + github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jinzhu/copier v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/kubescape/go-logger v0.0.26 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/mackerelio/go-osstat v0.2.6 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/olvrng/ujson v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/opencontainers/runtime-spec v1.2.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pkoukk/tiktoken-go v0.1.6 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/pkoukk/tiktoken-go v0.1.8 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/sasha-s/go-deadlock v0.3.6 // indirect + github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect + github.com/seccomp/libseccomp-golang v0.10.0 // indirect + github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/stripe/stripe-go/v74 v74.30.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/sylabs/squashfs v1.0.6 // indirect + github.com/therootcompany/xz v1.0.1 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect + github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect + github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2 // indirect + github.com/uptrace/uptrace-go v1.39.0 // indirect + github.com/vishvananda/netlink v1.3.2-0.20260109214200-c6faf428e8f8 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect + github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/yl2chen/cidranger v1.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/time v0.9.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + go.mongodb.org/mongo-driver v1.17.9 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 // indirect + go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/log v0.16.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.16.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/dig v1.19.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiserver v0.35.1 // indirect + k8s.io/component-base v0.35.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/controller-runtime v0.23.1 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index f2040cf5..8e473845 100644 --- a/go.sum +++ b/go.sum @@ -1,184 +1,1341 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= +cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= +cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= +cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= +cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= +cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= +cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= +cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM= +cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= +dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= +dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/acobaugh/osrelease v0.1.0 h1:Yb59HQDGGNhCj4suHaFQQfBps5wyoKLSSX/J/+UifRE= +github.com/acobaugh/osrelease v0.1.0/go.mod h1:4bFEs0MtgHNHBrmHCt67gNisnabCRAlzdVasCEGHTWY= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/anchore/clio v0.0.0-20250715152405-a0fa658e5084 h1:7DUAXEdAxoANPlDgxYiaSRKnWnTygvdrrWhnmvEjNLg= +github.com/anchore/clio v0.0.0-20250715152405-a0fa658e5084/go.mod h1:42dWox8z4//b898OIELsQnSdYq9q1aCXkwp5fKF+BEU= +github.com/anchore/fangs v0.0.0-20250716230140-94c22408c232 h1:aVC6r9h5wGNh8BYTW3CXxOdPoZzY/bBRWne1NvSTlO8= +github.com/anchore/fangs v0.0.0-20250716230140-94c22408c232/go.mod h1:Zees1AEKNpXIRgdVAMYWITncarLFiPOtEQ7rl45V/h0= +github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d h1:gT69osH9AsdpOfqxbRwtxcNnSZ1zg4aKy2BevO3ZBdc= +github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d/go.mod h1:PhSnuFYknwPZkOWKB1jXBNToChBA+l0FjwOxtViIc50= +github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 h1:2SqmFgE7h+Ql4VyBzhjLkRF/3gDrcpUBj8LjvvO6OOM= +github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722/go.mod h1:oFuE8YuTCM+spgMXhePGzk3asS94yO9biUfDzVTFqNw= +github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 h1:ZyRCmiEjnoGJZ1+Ah0ZZ/mKKqNhGcUZBl0s7PTTDzvY= +github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115/go.mod h1:KoYIv7tdP5+CC9VGkeZV4/vGCKsY55VvoG+5dadg4YI= +github.com/anchore/stereoscope v0.1.9 h1:Nhvk8g6PRx9ubaJU4asAhD3fGcY5HKXZCDGkxI2e0sI= +github.com/anchore/stereoscope v0.1.9/go.mod h1:YkrCtDgz7A+w6Ggd0yxU9q58CerqQFwYARS+F2RvLQQ= +github.com/anchore/syft v1.32.0 h1:JcX9W+P/Xjv5DNg3TNBtwiEyZommuTaP16/NC9r0Yfo= +github.com/anchore/syft v1.32.0/go.mod h1:E6Kd4iBM2ljUOUQvSt7hVK6vBwaHkMXwcvBZmGMSY5o= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armosec/armoapi-go v0.0.674 h1:jsp4rZqs+iKeL/Y4GgXW6JkVf0DhrilhaqRq3bar8HY= +github.com/armosec/armoapi-go v0.0.674/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts= +github.com/armosec/gojay v1.2.17 h1:VSkLBQzD1c2V+FMtlGFKqWXNsdNvIKygTKJI9ysY8eM= +github.com/armosec/gojay v1.2.17/go.mod h1:vuvX3DlY0nbVrJ0qCklSS733AWMoQboq3cFyuQW9ybc= +github.com/armosec/utils-go v0.0.58 h1:g9RnRkxZAmzTfPe2ruMo2OXSYLwVSegQSkSavOfmaIE= +github.com/armosec/utils-go v0.0.58/go.mod h1:CdqKHKruVJMCxGcZXYW9J+5P9FZou8dMzVpcB0Xt8pk= +github.com/armosec/utils-k8s-go v0.0.35 h1:CliNObhAca5UYl84m5OQecOTm9ZfMFI8648pYhQJiu4= +github.com/armosec/utils-k8s-go v0.0.35/go.mod h1:iHwR/KhMFtdd8Px1oYexLZYOHqmdknfGTZ8b7sZS0Ms= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= +github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= +github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= +github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cilium/cilium v1.19.0 h1:hfPhb9TcoG3fRMA1/ExBFihEX1NpEWcUaV1jXJTkQVo= +github.com/cilium/cilium v1.19.0/go.mod h1:yDDJNQbgFXDFdaVWlAWD8M3n8UZwHUk8Bo8JGgllx7o= +github.com/cilium/ebpf v0.20.1-0.20260108141042-f7e80f49188b h1:ubt7adiPfM2/6QrjNI8T+LAe4J7KHVkAMbEJ3+LLTXk= +github.com/cilium/ebpf v0.20.1-0.20260108141042-f7e80f49188b/go.mod h1:dM+AMI6FkW5LOkzikdefUmzK0z81o7GqiKXon7D1F58= +github.com/cilium/hive v0.0.1 h1:NrHJ1DD74B77ib4UhujEQ0j4nCQmyKOic9qtwrreJRs= +github.com/cilium/hive v0.0.1/go.mod h1:4/8FBMcTjVdkrNNWaB7t3QqaU4kZDJLJ1leKVP9GjEI= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containers/common v0.63.0 h1:ox6vgUYX5TSvt4W+bE36sYBVz/aXMAfRGVAgvknSjBg= +github.com/containers/common v0.63.0/go.mod h1:+3GCotSqNdIqM3sPs152VvW7m5+Mg8Kk+PExT3G9hZw= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= -github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +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= +github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/cli v28.3.3+incompatible h1:fp9ZHAr1WWPGdIWBM1b3zLtgCF+83gRdVMTJsUeiyAo= +github.com/docker/cli v28.3.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= +github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c/go.mod h1:QGzNH9ujQ2ZUr/CjDGZGWeDAVStrWNjHeEcjJL96Nuk= +github.com/facebookincubator/nvdtools v0.1.5 h1:jbmDT1nd6+k+rlvKhnkgMokrCAzHoASWE5LtHbX2qFQ= +github.com/facebookincubator/nvdtools v0.1.5/go.mod h1:Kh55SAWnjckS96TBSrXI99KrEKH4iB0OJby3N8GRJO4= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= +github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= +github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= +github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/github/go-spdx/v2 v2.3.3 h1:QI7evnHWEfWkT54eJwkoV/f3a0xD3gLlnVmT5wQG6LE= +github.com/github/go-spdx/v2 v2.3.3/go.mod h1:2ZxKsOhvBp+OYBDlsGnUMcchLeo2mrpEBn2L1C+U3IQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-snaps v0.5.19 h1:hUJlCQOpTt1M+kSisMwioDWZDWpDtdAvUhvWCx1YGW0= +github.com/gkampitakis/go-snaps v0.5.19/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= +github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/analysis v0.24.2 h1:6p7WXEuKy1llDgOH8FooVeO+Uq2za9qoAOq4ZN08B50= +github.com/go-openapi/analysis v0.24.2/go.mod h1:x27OOHKANE0lutg2ml4kzYLoHGMKgRm1Cj2ijVOjJuE= +github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo= +github.com/go-openapi/errors v0.22.6/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/loads v0.23.2 h1:rJXAcP7g1+lWyBHC7iTY+WAF0rprtM+pm8Jxv1uQJp4= +github.com/go-openapi/loads v0.23.2/go.mod h1:IEVw1GfRt/P2Pplkelxzj9BYFajiWOtY2nHZNj4UnWY= +github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc= +github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= +github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ= +github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= +github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= +github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= +github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= +github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= +github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= +github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE= +github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= +github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= +github.com/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= +github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= +github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 h1:NpbJl/eVbvrGE0MJ6X16X9SAifesl6Fwxg/YmCvubRI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8/go.mod h1:mi7YA+gCzVem12exXy46ZespvGtX/lZmD/RLnQhVW7U= +github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= +github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= +github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= +github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= +github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8= -github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/kubescape/go-logger v0.0.26 h1:pOADnCLaooXwa7XUzmelCasq8h/vWhjHScJITuKoTUk= +github.com/kubescape/go-logger v0.0.26/go.mod h1:qfUg4BGH2Rbxy+Tn3g4ks2Lt7zOENKA8AMXnAfkZpl0= +github.com/kubescape/k8s-interface v0.0.203 h1:dMlF+X8PQPUhcn9QeMipnHd/6/gkOvyC0smLz1uBO5I= +github.com/kubescape/k8s-interface v0.0.203/go.mod h1:d4NVhL81bVXe8yEXlkT4ZHrt3iEppEIN39b8N1oXm5s= +github.com/kubescape/storage v0.0.239 h1:hfuq1+CuEAKE7zCg9bB8gfU9vZoGMrJBgNh5tAD1rak= +github.com/kubescape/storage v0.0.239/go.mod h1:f6u/Lt3SjUTBrmzOStb33IkKTtaqKM4pyfV5d1lUMiY= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= +github.com/mackerelio/go-osstat v0.2.6 h1:gs4U8BZeS1tjrL08tt5VUliVvSWP26Ai2Ob8Lr7f2i0= +github.com/mackerelio/go-osstat v0.2.6/go.mod h1:lRy8V9ZuHpuRVZh+vyTkODeDPl3/d5MgXHtLSaqG8bA= +github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= -github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= -github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= +github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olvrng/ujson v1.1.0 h1:8xVUzVlqwdMVWh5d1UHBtLQ1D50nxoPuPEq9Wozs8oA= +github.com/olvrng/ujson v1.1.0/go.mod h1:Mz4G3RODTUfbkKyvi0lgmPx/7vd3Saksk+1jgk8s9xo= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= +github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-tools v0.9.1-0.20250303011046-260e151b8552 h1:CkXngT0nixZqQUPDVfwVs3GiuhfTqCMk0V+OoHpxIvA= +github.com/opencontainers/runtime-tools v0.9.1-0.20250303011046-260e151b8552/go.mod h1:T487Kf80NeF2i0OyVXHiylg217e0buz8pQsa0T791RA= +github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pborman/indent v1.2.1 h1:lFiviAbISHv3Rf0jcuh489bi06hj98JsVMtIDZQb9yM= +github.com/pborman/indent v1.2.1/go.mod h1:FitS+t35kIYtB5xWTZAPhnmrxcciEEOdbyrrpz5K6Vw= +github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw= -github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= +github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/sasha-s/go-deadlock v0.3.6 h1:TR7sfOnZ7x00tWPfD397Peodt57KzMDo+9Ae9rMiUmw= +github.com/sasha-s/go-deadlock v0.3.6/go.mod h1:CUqNyyvMxTyjFqDT7MRg9mb4Dv/btmGTqSR+rky/UXo= +github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= +github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/seccomp/libseccomp-golang v0.10.0 h1:aA4bp+/Zzi0BnWZ2F1wgNBs5gTpm+na2rWM6M9YjLpY= +github.com/seccomp/libseccomp-golang v0.10.0/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= +github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= +github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= +github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= +github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= +github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= +github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= +github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= +github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= +github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= +github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= +github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= +github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= +github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= +github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= +github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= +github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= +github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= +github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tmc/langchaingo v0.1.13 h1:rcpMWBIi2y3B90XxfE4Ao8dhCQPVDMaNPnN5cGB1CaA= -github.com/tmc/langchaingo v0.1.13/go.mod h1:vpQ5NOIhpzxDfTZK9B6tf2GM/MoaHewPWM5KXXGh7hg= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY= +github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/sylabs/squashfs v1.0.6 h1:PvJcDzxr+vIm2kH56mEMbaOzvGu79gK7P7IX+R7BDZI= +github.com/sylabs/squashfs v1.0.6/go.mod h1:DlDeUawVXLWAsSRa085Eo0ZenGzAB32JdAUFaB0LZfE= +github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI= +github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= +github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tmc/langchaingo v0.1.14 h1:o1qWBPigAIuFvrG6cjTFo0cZPFEZ47ZqpOYMjM15yZc= +github.com/tmc/langchaingo v0.1.14/go.mod h1:aKKYXYoqhIDEv7WKdpnnCLRaqXic69cX9MnDUk72378= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 h1:3/aHKUq7qaFMWxyQV0W2ryNgg8x8rVeKVA20KJUkfS0= +github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2/go.mod h1:Zit4b8AQXaXvA68+nzmbyDzqiyFRISyw1JiD5JqUBjw= +github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2 h1:cj/Z6FKTTYBnstI0Lni9PA+k2foounKIPUmj1LBwNiQ= +github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2/go.mod h1:LDaXk90gKEC2nC7JH3Lpnhfu+2V7o/TsqomJJmqA39o= +github.com/uptrace/uptrace-go v1.39.0 h1:MszuE3eX/z86xzYywN2JBtYcmsS4ofdo1VMDhRvkWrI= +github.com/uptrace/uptrace-go v1.39.0/go.mod h1:FquipEqgTMXPbhdhenjbiLHG1R5WYdxVH6zgwHeMzzA= +github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= +github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= +github.com/vishvananda/netlink v1.3.2-0.20260109214200-c6faf428e8f8 h1:/EaCkwYyCH9rDgccb78ZTaGwo7UGjjdh0iyCa3+miRs= +github.com/vishvananda/netlink v1.3.2-0.20260109214200-c6faf428e8f8/go.mod h1:lEui7SPMd9fgxzHVGRAvTxsBGCF6PRH81o2kLWLWHgw= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= +github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= +github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8= +github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= +github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= +go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= +go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4= +go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk= +go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0 h1:n8qdwrebNEHF/zHpueuZ4OacdJ8CdSaP7xef9WRZXTQ= +go.opentelemetry.io/contrib/instrumentation/runtime v0.65.0/go.mod h1:Z1pjGxUL3nJ/IbDDfL6rBD0Xbz7ZOViRqrIUg4l1CYE= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= +go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= +go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= +go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4= +go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= -golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= +google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= +google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= +google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= +google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= +google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= +google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= +google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= +google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= +google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= +google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= -k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= -k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= -k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= -k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= -k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= +gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= +gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= +k8s.io/apiserver v0.35.1/go.mod h1:BiL6Dd3A2I/0lBnteXfWmCFobHM39vt5+hJQd7Lbpi4= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= +k8s.io/component-base v0.35.1 h1:XgvpRf4srp037QWfGBLFsYMUQJkE5yMa94UsJU7pmcE= +k8s.io/component-base v0.35.1/go.mod h1:HI/6jXlwkiOL5zL9bqA3en1Ygv60F03oEpnuU1G56Bs= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= -sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= +sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/helm/kagent-tools/Chart-template.yaml b/helm/kagent-tools/Chart-template.yaml new file mode 100644 index 00000000..3d2eb2b2 --- /dev/null +++ b/helm/kagent-tools/Chart-template.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: kagent-tools +description: A Helm chart for kagent-tools +type: application +version: ${VERSION} +appVersion: ${VERSION} \ No newline at end of file diff --git a/helm/kagent-tools/templates/NOTES.txt b/helm/kagent-tools/templates/NOTES.txt new file mode 100644 index 00000000..2876aa1e --- /dev/null +++ b/helm/kagent-tools/templates/NOTES.txt @@ -0,0 +1,10 @@ +################################ To open kagent UI: ########################################### +# +# This is a Helm chart for Kagent Tools +# +# 1. Forward application port by running these commands in the terminal: +# kubectl -n {{ include "kagent-tools.namespace" . }} port-forward service/{{ .Release.Name }} {{.Values.service.ports.tools.targetPort}}:{{.Values.service.ports.tools.port}} & +# +# 2. Then visit http://127.0.0.1:{{.Values.service.ports.tools.port}}/mcp to use MCP +# +############################################################################################### \ No newline at end of file diff --git a/helm/kagent-tools/templates/_helpers.tpl b/helm/kagent-tools/templates/_helpers.tpl new file mode 100644 index 00000000..f2d27f37 --- /dev/null +++ b/helm/kagent-tools/templates/_helpers.tpl @@ -0,0 +1,101 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "kagent-tools.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "kagent-tools.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- if not .Values.nameOverride }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "kagent-tools.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "kagent-tools.labels" -}} +helm.sh/chart: {{ include "kagent-tools.chart" . }} +{{ include "kagent-tools.selectorLabels" . }} +{{- if .Chart.Version }} +app.kubernetes.io/version: {{ .Chart.Version | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "kagent-tools.selectorLabels" -}} +app.kubernetes.io/name: {{ include "kagent-tools.fullname" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/*Default provider name*/}} +{{- define "kagent-tools.defaultProviderName" -}} +{{ .Values.providers.default | default "openAI" | lower}} +{{- end }} + +{{/*Default model name*/}} +{{- define "kagent-tools.defaultModelConfigName" -}} +default-model-config +{{- end }} + +{{/* +Expand the namespace of the release. +Allows overriding it for multi-namespace deployments in combined charts. +*/}} +{{- define "kagent-tools.namespace" -}} +{{- default .Release.Namespace .Values.namespaceOverride | trunc 63 | trimSuffix "-" -}} +{{- end }} + +{{/* +Service account name: default when useDefaultServiceAccount is true, otherwise the chart fullname. +*/}} +{{- define "kagent-tools.serviceAccountName" -}} +{{- if .Values.useDefaultServiceAccount }}default{{- else }}{{ include "kagent-tools.fullname" . }}{{- end }} +{{- end }} + +{{/* +Watch namespaces - comma-separated list for controllers that watch a subset of namespaces. +Precedence: controller.watchNamespaces (explicit override) > rbac.namespaces > empty (watch all). +*/}} +{{- define "kagent-tools.watchNamespaces" -}} +{{- $ctrl := index .Values "controller" | default dict -}} +{{- if index $ctrl "watchNamespaces" -}} +{{- index $ctrl "watchNamespaces" | uniq | join "," -}} +{{- else if and .Values.rbac .Values.rbac.namespaces -}} +{{- .Values.rbac.namespaces | uniq | join "," -}} +{{- end -}} +{{- end }} + +{{/* +Guards on the rbac block +*/}} +{{- define "kagent-tools.rbac.validate" -}} +{{- if and .Values.rbac (hasKey .Values.rbac "clusterScoped") -}} +{{- fail "rbac.clusterScoped has been removed. Leave rbac.namespaces empty for cluster-scoped RBAC, or set rbac.namespaces=[, ...] for namespaced RBAC." -}} +{{- end -}} +{{- if and .Values.rbac .Values.rbac.namespaces -}} +{{- $installNs := include "kagent-tools.namespace" . -}} +{{- if not (has $installNs .Values.rbac.namespaces) -}} +{{- fail (printf "rbac.namespaces is set but does not include the install namespace %q" $installNs) -}} +{{- end -}} +{{- end -}} +{{- end -}} diff --git a/helm/kagent-tools/templates/clusterrole.yaml b/helm/kagent-tools/templates/clusterrole.yaml new file mode 100644 index 00000000..2ddb85d4 --- /dev/null +++ b/helm/kagent-tools/templates/clusterrole.yaml @@ -0,0 +1,118 @@ +{{- define "kagent-tools.rules" -}} +{{- if .Values.rbac.readOnly }} +# Core workload resources +- apiGroups: [""] + resources: + - pods + - services + - endpoints + - configmaps + - serviceaccounts + - persistentvolumeclaims + - replicationcontrollers + - namespaces + verbs: ["get", "list", "watch"] + +# Pod logs (subresource) +- apiGroups: [""] + resources: + - pods/log + verbs: ["get", "list"] + +# Events +- apiGroups: [""] + resources: + - events + verbs: ["get", "list", "watch"] +- apiGroups: ["events.k8s.io"] + resources: + - events + verbs: ["get", "list", "watch"] + +# Apps workloads +- apiGroups: ["apps"] + resources: + - deployments + - statefulsets + - daemonsets + - replicasets + verbs: ["get", "list", "watch"] + +# Batch workloads +- apiGroups: ["batch"] + resources: + - jobs + - cronjobs + verbs: ["get", "list", "watch"] + +# Networking +- apiGroups: ["networking.k8s.io"] + resources: + - ingresses + - networkpolicies + verbs: ["get", "list", "watch"] + +# Autoscaling +- apiGroups: ["autoscaling"] + resources: + - horizontalpodautoscalers + verbs: ["get", "list", "watch"] + +{{- if .Values.rbac.allowSecrets }} +# Secrets (opt-in) +- apiGroups: [""] + resources: + - secrets + verbs: ["get", "list", "watch"] +{{- end }} + +{{- with .Values.rbac.additionalRules }} +# Additional user-defined rules +{{- toYaml . | nindent 0 }} +{{- end }} +{{- else }} +- apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] +{{- if not .Values.rbac.namespaces }} +- nonResourceURLs: ["*"] + verbs: ["*"] +{{- end }} +{{- end }} +{{- end -}} + +{{- if and (not .Values.useDefaultServiceAccount) .Values.rbac.create }} +{{- include "kagent-tools.rbac.validate" . -}} +{{- if .Values.rbac.namespaces }} +{{- range $namespace := (.Values.rbac.namespaces | uniq | sortAlpha) }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + {{- if $.Values.rbac.readOnly }} + name: {{ include "kagent-tools.fullname" $ }}-read-role + {{- else }} + name: {{ include "kagent-tools.fullname" $ }}-cluster-admin-role + {{- end }} + namespace: {{ $namespace }} + labels: + {{- include "kagent-tools.labels" $ | nindent 4 }} +rules: + {{- include "kagent-tools.rules" $ | nindent 2 }} +{{- end }} + +{{- else }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + {{- if .Values.rbac.readOnly }} + name: {{ include "kagent-tools.fullname" . }}-read-role + {{- else }} + name: {{ include "kagent-tools.fullname" . }}-cluster-admin-role + {{- end }} + labels: + {{- include "kagent-tools.labels" . | nindent 4 }} +rules: + {{- include "kagent-tools.rules" . | nindent 2 }} +{{- end }} +{{- end }} diff --git a/helm/kagent-tools/templates/clusterrolebinding.yaml b/helm/kagent-tools/templates/clusterrolebinding.yaml new file mode 100644 index 00000000..43240435 --- /dev/null +++ b/helm/kagent-tools/templates/clusterrolebinding.yaml @@ -0,0 +1,57 @@ +{{- if and (not .Values.useDefaultServiceAccount) .Values.rbac.create }} +{{- include "kagent-tools.rbac.validate" . -}} + +{{- if .Values.rbac.namespaces }} +{{- range $namespace := (.Values.rbac.namespaces | uniq | sortAlpha) }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + {{- if $.Values.rbac.readOnly }} + name: {{ include "kagent-tools.fullname" $ }}-read-rolebinding + {{- else }} + name: {{ include "kagent-tools.fullname" $ }}-cluster-admin-rolebinding + {{- end }} + namespace: {{ $namespace }} + labels: + {{- include "kagent-tools.labels" $ | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + {{- if $.Values.rbac.readOnly }} + name: {{ include "kagent-tools.fullname" $ }}-read-role + {{- else }} + name: {{ include "kagent-tools.fullname" $ }}-cluster-admin-role + {{- end }} +subjects: +- kind: ServiceAccount + name: {{ include "kagent-tools.fullname" $ }} + namespace: {{ include "kagent-tools.namespace" $ }} +{{- end }} + +{{- else }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + {{- if .Values.rbac.readOnly }} + name: {{ include "kagent-tools.fullname" . }}-read-rolebinding + {{- else }} + name: {{ include "kagent-tools.fullname" . }}-cluster-admin-rolebinding + {{- end }} + labels: + {{- include "kagent-tools.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + {{- if .Values.rbac.readOnly }} + name: {{ include "kagent-tools.fullname" . }}-read-role + {{- else }} + name: {{ include "kagent-tools.fullname" . }}-cluster-admin-role + {{- end }} +subjects: +- kind: ServiceAccount + name: {{ include "kagent-tools.fullname" . }} + namespace: {{ include "kagent-tools.namespace" . }} +{{- end }} + +{{- end }} diff --git a/helm/kagent-tools/templates/deployment.yaml b/helm/kagent-tools/templates/deployment.yaml new file mode 100644 index 00000000..c4b4a354 --- /dev/null +++ b/helm/kagent-tools/templates/deployment.yaml @@ -0,0 +1,114 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kagent-tools.fullname" . }} + namespace: {{ include "kagent-tools.namespace" . }} + labels: + {{- include "kagent-tools.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "kagent-tools.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "kagent-tools.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + affinity: + {{- with .Values.affinity }} + {{- toYaml . | nindent 8 }} + {{- end }} + + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: + {{- range . }} + - {{ toYaml . | nindent 10 | trim }} + {{- if not (hasKey . "labelSelector") }} + labelSelector: + matchLabels: + {{- include "kagent-tools.selectorLabels" $ | nindent 14 }} + {{- end }} + {{- end }} + {{- end }} + + + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + serviceAccountName: {{ include "kagent-tools.serviceAccountName" . }} + containers: + - name: tools + command: + - /tool-server + args: + - "--port" + - "{{ .Values.service.ports.tools.targetPort }}" + - "--metrics-port" + - "{{ .Values.tools.metrics.port | default .Values.service.ports.tools.targetPort }}" + {{- if .Values.tools.enabledTools }} + - "--tools={{ join "," .Values.tools.enabledTools }}" + {{- end }} + {{- with .Values.tools.args }} + {{- toYaml . | nindent 10 }} + {{- end }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.tools.image.registry }}/{{ .Values.tools.image.repository }}:{{ coalesce .Values.global.tag .Values.tools.image.tag .Chart.Version }}" + imagePullPolicy: {{ .Values.tools.image.pullPolicy }} + resources: + {{- toYaml .Values.tools.resources | nindent 12 }} + env: + - name: KAGENT_NAMESPACE + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "kagent-tools.fullname" . }}-openai + key: OPENAI_API_KEY + optional: true # if the secret is not found, the tool will not be available + - name: OTEL_TRACING_ENABLED + value: {{ .Values.otel.tracing.enabled | quote }} + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ .Values.otel.tracing.exporter.otlp.endpoint | quote }} + - name: OTEL_EXPORTER_OTLP_TRACES_TIMEOUT + value: {{ .Values.otel.tracing.exporter.otlp.timeout | quote }} + - name: OTEL_EXPORTER_OTLP_TRACES_INSECURE + value: {{ .Values.otel.tracing.exporter.otlp.insecure | quote }} + - name: TOKEN_PASSTHROUGH + value: {{ (index .Values.tools "k8s" | default dict).tokenPassthrough | default false | quote }} + {{- with .Values.tools.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http-tools + containerPort: {{ .Values.service.ports.tools.targetPort }} + protocol: TCP + - name: http-metrics + containerPort: {{ .Values.tools.metrics.port | default .Values.service.ports.tools.targetPort }} + protocol: TCP + readinessProbe: + httpGet: + path: /health + port: http-tools + initialDelaySeconds: 15 + periodSeconds: 15 + diff --git a/helm/kagent-tools/templates/service.yaml b/helm/kagent-tools/templates/service.yaml new file mode 100644 index 00000000..06bcb67d --- /dev/null +++ b/helm/kagent-tools/templates/service.yaml @@ -0,0 +1,40 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kagent-tools.fullname" . }} + namespace: {{ include "kagent-tools.namespace" . }} + labels: + {{- include "kagent-tools.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.ports.tools.port }} + targetPort: {{ .Values.service.ports.tools.targetPort }} + {{- if eq .Values.service.type "NodePort" }} # Only used if service.type is NodePort + {{- if .Values.service.ports.tools.nodePort }} + nodePort: {{ .Values.service.ports.tools.nodePort | default "" }} + {{- end }} + {{- end }} + protocol: TCP + name: tools + selector: + {{- include "kagent-tools.selectorLabels" . | nindent 4 }} + +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kagent-tools.fullname" . }}-metrics + namespace: {{ include "kagent-tools.namespace" . }} + labels: + {{- include "kagent-tools.labels" . | nindent 4 }} + app.kubernetes.io/component: metrics +spec: + selector: + {{- include "kagent-tools.selectorLabels" . | nindent 4 }} + ports: + - name: prometheus-metrics + protocol: TCP + port: {{ .Values.tools.metrics.port | default .Values.service.ports.tools.targetPort }} + targetPort: {{ .Values.tools.metrics.port | default .Values.service.ports.tools.targetPort }} + \ No newline at end of file diff --git a/helm/kagent-tools/templates/serviceaccount.yaml b/helm/kagent-tools/templates/serviceaccount.yaml new file mode 100644 index 00000000..da39344e --- /dev/null +++ b/helm/kagent-tools/templates/serviceaccount.yaml @@ -0,0 +1,9 @@ +{{- if not .Values.useDefaultServiceAccount }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "kagent-tools.fullname" . }} + namespace: {{ include "kagent-tools.namespace" . }} + labels: + {{- include "kagent-tools.labels" . | nindent 4 }} +{{- end }} diff --git a/helm/kagent-tools/templates/servicemonitor.yaml b/helm/kagent-tools/templates/servicemonitor.yaml new file mode 100644 index 00000000..c13c5d82 --- /dev/null +++ b/helm/kagent-tools/templates/servicemonitor.yaml @@ -0,0 +1,23 @@ + +{{- if .Values.tools.metrics.servicemonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "kagent-tools.fullname" . }} + namespace: {{ include "kagent-tools.namespace" . }} + labels: + {{- toYaml .Values.tools.metrics.servicemonitor.labels | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "kagent-tools.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: metrics + namespaceSelector: + matchNames: + - {{ include "kagent-tools.namespace" . }} + endpoints: + - port: prometheus-metrics + interval: {{ .Values.tools.metrics.servicemonitor.interval | default "30s" }} + scrapeTimeout: {{ .Values.tools.metrics.servicemonitor.scrapeTimeout | default "10s" }} + path: {{ .Values.tools.metrics.servicemonitor.path | default "/metrics" }} +{{- end }} diff --git a/helm/kagent-tools/tests/deployment_test.yaml b/helm/kagent-tools/tests/deployment_test.yaml new file mode 100644 index 00000000..84bdaae0 --- /dev/null +++ b/helm/kagent-tools/tests/deployment_test.yaml @@ -0,0 +1,175 @@ +suite: test controller deployment +templates: + - deployment.yaml +tests: + - it: should render deployment with default values + template: deployment.yaml + asserts: + - isKind: + of: Deployment + - equal: + path: metadata.name + value: RELEASE-NAME + - equal: + path: spec.replicas + value: 1 + - hasDocuments: + count: 1 + + - it: should render deployment with custom replica count + template: deployment.yaml + set: + replicaCount: 3 + asserts: + - equal: + path: spec.replicas + value: 3 + + - it: should have correct container image + template: deployment.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].name + value: tools + pattern: "^cr\\.kagent\\.dev/kagent-dev/kagent/tools:.+" + + - it: should use global tag when set + template: deployment.yaml + set: + tools: + image: + tag: "v1.0.0" + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/kagent-dev/kagent/tools:v1.0.0 + + - it: should have correct resources + template: deployment.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].resources.requests.cpu + value: 100m + - equal: + path: spec.template.spec.containers[0].resources.requests.memory + value: 128Mi + - equal: + path: spec.template.spec.containers[0].resources.limits.cpu + value: 1000m + - equal: + path: spec.template.spec.containers[0].resources.limits.memory + value: 512Mi + + - it: should use default service account when useDefaultServiceAccount is true + template: deployment.yaml + set: + useDefaultServiceAccount: true + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: default + + - it: should use dedicated service account when useDefaultServiceAccount is false + template: deployment.yaml + set: + useDefaultServiceAccount: false + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: RELEASE-NAME + + - it: should set token passthrough env when tools.k8s.tokenPassthrough is true + template: deployment.yaml + set: + tools.k8s.tokenPassthrough: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: TOKEN_PASSTHROUGH + value: "true" + + - it: should set token passthrough env when tools.k8s.tokenPassthrough is false + template: deployment.yaml + set: + tools.k8s.tokenPassthrough: false + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: TOKEN_PASSTHROUGH + value: "false" + + - it: should have correct container port + template: deployment.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].ports[0].containerPort + value: 8084 + + - it: should set nodeSelector + set: + nodeSelector: + role: AI + asserts: + - equal: + path: spec.template.spec.nodeSelector + value: + role: AI + + - it: should set tolerations + set: + tolerations: + - key: role + operator: Equal + value: AI + effect: NoSchedule + asserts: + - contains: + any: true + path: spec.template.spec.tolerations + content: + key: role + value: AI + effect: NoSchedule + operator: Equal + + - it: should render custom node affinity from values + set: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/e2e-az-name + operator: In + values: + - e2e-az1 + asserts: + - equal: + path: spec.template.spec.affinity + value: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/e2e-az-name + operator: In + values: + - e2e-az1 + + - it: should render topologySpreadConstraints with labelSelector fallback + set: + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: zone + whenUnsatisfiable: ScheduleAnyway + asserts: + - equal: + path: spec.template.spec.topologySpreadConstraints[0].topologyKey + value: zone + - equal: + path: spec.template.spec.topologySpreadConstraints[0].labelSelector.matchLabels + value: + app.kubernetes.io/name: RELEASE-NAME + app.kubernetes.io/instance: RELEASE-NAME diff --git a/helm/kagent-tools/tests/rbac_test.yaml b/helm/kagent-tools/tests/rbac_test.yaml new file mode 100644 index 00000000..41a991e3 --- /dev/null +++ b/helm/kagent-tools/tests/rbac_test.yaml @@ -0,0 +1,133 @@ +suite: test rbac +templates: + - clusterrole.yaml + - clusterrolebinding.yaml +tests: + - it: should render clusterroles by default + asserts: + - isKind: + of: ClusterRole + template: clusterrole.yaml + - isKind: + of: ClusterRoleBinding + template: clusterrolebinding.yaml + + - it: should render Roles when rbac.namespaces is set + set: + rbac.namespaces: + - NAMESPACE + asserts: + - isKind: + of: Role + template: clusterrole.yaml + - isKind: + of: RoleBinding + template: clusterrolebinding.yaml + - equal: + path: metadata.namespace + value: NAMESPACE + template: clusterrole.yaml + - equal: + path: metadata.namespace + value: NAMESPACE + template: clusterrolebinding.yaml + + - it: should render a single role/binding in the listed namespace only (no release-ns fallback) + set: + rbac.namespaces: + - NAMESPACE + asserts: + - hasDocuments: + count: 1 + template: clusterrole.yaml + - equal: + path: metadata.namespace + value: NAMESPACE + template: clusterrole.yaml + - hasDocuments: + count: 1 + template: clusterrolebinding.yaml + - equal: + path: metadata.namespace + value: NAMESPACE + template: clusterrolebinding.yaml + + - it: should render multiple roles and bindings when namespaces are specified + set: + rbac.namespaces: + - ns1 + - NAMESPACE + - ns2 + asserts: + - hasDocuments: + count: 3 + template: clusterrole.yaml + - equal: + path: metadata.namespace + value: NAMESPACE + template: clusterrole.yaml + documentIndex: 0 + - equal: + path: metadata.namespace + value: ns1 + template: clusterrole.yaml + documentIndex: 1 + - equal: + path: metadata.namespace + value: ns2 + template: clusterrole.yaml + documentIndex: 2 + - hasDocuments: + count: 3 + template: clusterrolebinding.yaml + - equal: + path: metadata.namespace + value: NAMESPACE + template: clusterrolebinding.yaml + documentIndex: 0 + - equal: + path: metadata.namespace + value: ns1 + template: clusterrolebinding.yaml + documentIndex: 1 + - equal: + path: metadata.namespace + value: ns2 + template: clusterrolebinding.yaml + documentIndex: 2 + + - it: should fail rendering if the removed rbac.clusterScoped field is set + set: + rbac.clusterScoped: false + template: clusterrolebinding.yaml + asserts: + - failedTemplate: + errorMessage: "rbac.clusterScoped has been removed. Leave rbac.namespaces empty for cluster-scoped RBAC, or set rbac.namespaces=[, ...] for namespaced RBAC." + + - it: should fail rendering if rbac.namespaces is set but does not include the install namespace + set: + rbac.namespaces: + - some-other-ns + template: clusterrolebinding.yaml + asserts: + - failedTemplate: + errorMessage: 'rbac.namespaces is set but does not include the install namespace "NAMESPACE"' + + - it: should accept a custom install namespace when listed in rbac.namespaces + set: + namespaceOverride: my-ns + rbac.namespaces: + - my-ns + - other-ns + template: clusterrole.yaml + asserts: + - hasDocuments: + count: 2 + - equal: + path: metadata.namespace + value: my-ns + documentIndex: 0 + - equal: + path: metadata.namespace + value: other-ns + documentIndex: 1 diff --git a/helm/kagent-tools/values.yaml b/helm/kagent-tools/values.yaml new file mode 100644 index 00000000..b398a00c --- /dev/null +++ b/helm/kagent-tools/values.yaml @@ -0,0 +1,133 @@ +# Default values for kagent +replicaCount: 1 + +# When true: pods use the default service account and no ClusterRole/ClusterRoleBinding are created. +# When false: a dedicated ServiceAccount and RBAC are created. +useDefaultServiceAccount: false + +global: + tag: "" + +tools: + metrics: + # port defaults to the main --port value (same server). Set explicitly for a dedicated metrics port. + port: "" + servicemonitor: + enabled: false + interval: 30s + scrapeTimeout: 10s + labels: + release: prometheus + loglevel: "debug" + # List of tool providers to enable. Empty list means all tools are enabled. + # Available: k8s, helm, istio, cilium, argo, prometheus, kubescape, utils + enabledTools: [] + # - k8s + # - helm + # - prometheus + # Additional command-line arguments + args: [] + # - "--some-future-flag" + image: + registry: ghcr.io + repository: kagent-dev/kagent/tools + tag: "" + pullPolicy: IfNotPresent + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 1000m + memory: 512Mi + k8s: + # When true: a Bearer token in the Authorization header on each request is passed to kubectl; fails if missing + # When false: kubectl uses in-cluster ServiceAccount. + tokenPassthrough: false + prometheus: + url: "prometheus.kagent.svc.cluster.local:9090" + username: "" + password: "" + grafana: # kubectl port-forward svc/grafana 3000:3000 + url: "http://grafana.kagent.svc.cluster.local:3000" + apiKey: "" + +service: + type: ClusterIP + ports: + tools: + port: 8084 + targetPort: 8084 + nodePort: # Only used if service.type is NodePort + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# Node taints which will be tolerated for Pod. +tolerations: [] + +# Node labels to match for Pod. +nodeSelector: {} + +# Affinity settings for Pod. +affinity: {} +# nodeAffinity: +# requiredDuringSchedulingIgnoredDuringExecution: +# nodeSelectorTerms: +# - matchExpressions: +# - key: kubernetes.io/e2e-az-name +# operator: In +# values: +# - e2e-az1 + +# Topology spread constraints for Pod. +topologySpreadConstraints: [] +# - maxSkew: 1 +# topologyKey: topology.kubernetes.io/zone +# whenUnsatisfiable: DoNotSchedule + +rbac: + # When false, no ClusterRole or ClusterRoleBinding are created. + # The ServiceAccount is still created allowing you to attach your own roles externally. + create: true + # -- Namespaces in which to create Role and RoleBinding resources. + # If empty (default), the chart creates cluster-scoped ClusterRole and ClusterRoleBinding resources. + # If set, the chart creates a Role + RoleBinding per listed namespace (install namespace must be included). + namespaces: [] + # When true, deploys a read-only ClusterRole (get, list, watch) instead of cluster-admin. + # Pairs well with the --read-only CLI flag which disables write operations at the application layer. + # Only applies when rbac.create is true. + # Defaults to False as to not introduce breaking changes. Worth revisiting later on. + readOnly: false + # When readOnly is true, grants read access to Secrets. Default: no access to Secrets. + # Ignored when readOnly is false (admin already has full access). + allowSecrets: false + # Extra rules appended to the read-only ClusterRole. Useful for granting read access to CRDs (Istio, Cilium, Argo, etc.). + # Ignored when readOnly is false (admin already has full access). + additionalRules: [] + # - apiGroups: ["networking.istio.io"] + # resources: ["virtualservices", "destinationrules", "gateways"] + # verbs: ["get", "list", "watch"] + +otel: + tracing: + enabled: false + exporter: + otlp: + endpoint: http://host.docker.internal:4317 + timeout: 15 + insecure: true diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 00000000..4c2c1056 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,545 @@ +package cache + +import ( + "context" + "fmt" + "sync" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/kagent-dev/tools/internal/logger" + "github.com/kagent-dev/tools/internal/telemetry" +) + +// CacheType represents the type of cache using enum pattern +type CacheType int + +const ( + CacheTypeKubernetes CacheType = iota + CacheTypeCommand + CacheTypeHelm + CacheTypeIstio +) + +// String returns the string representation of CacheType +func (ct CacheType) String() string { + switch ct { + case CacheTypeKubernetes: + return "kubernetes" + case CacheTypeCommand: + return "command" + case CacheTypeHelm: + return "helm" + case CacheTypeIstio: + return "istio" + default: + return "unknown" + } +} + +// Command to cache type mapping +var commandToCacheType = map[string]CacheType{ + "kubectl": CacheTypeKubernetes, + "helm": CacheTypeHelm, + "istioctl": CacheTypeIstio, + "cilium": CacheTypeCommand, // Use command cache for cilium + "argo": CacheTypeCommand, // Use command cache for argo +} + +// CacheEntry represents a cached item with TTL +type CacheEntry[T any] struct { + Value T + CreatedAt time.Time + ExpiresAt time.Time + AccessedAt time.Time + AccessCount int64 +} + +// IsExpired checks if the cache entry has expired +func (e *CacheEntry[T]) IsExpired() bool { + return time.Now().After(e.ExpiresAt) +} + +// Cache is a thread-safe cache with TTL support +type Cache[T any] struct { + mu sync.RWMutex + data map[string]*CacheEntry[T] + name string + defaultTTL time.Duration + maxSize int + cleanupInterval time.Duration + stopCleanup chan struct{} + + // Metrics + hits metric.Int64Counter + misses metric.Int64Counter + evictions metric.Int64Counter + size metric.Int64UpDownCounter +} + +// NewCache creates a new cache with specified configuration and name +func NewCache[T any](name string, defaultTTL time.Duration, maxSize int, cleanupInterval time.Duration) *Cache[T] { + meter := otel.Meter(fmt.Sprintf("kagent-tools/cache/%s", name)) + + // Create metrics with cache name as a label + hits, _ := meter.Int64Counter( + "cache_hits_total", + metric.WithDescription("Total number of cache hits"), + ) + + misses, _ := meter.Int64Counter( + "cache_misses_total", + metric.WithDescription("Total number of cache misses"), + ) + + evictions, _ := meter.Int64Counter( + "cache_evictions_total", + metric.WithDescription("Total number of cache evictions"), + ) + + size, _ := meter.Int64UpDownCounter( + "cache_size", + metric.WithDescription("Current number of items in cache"), + ) + + cache := &Cache[T]{ + data: make(map[string]*CacheEntry[T]), + name: name, + defaultTTL: defaultTTL, + maxSize: maxSize, + cleanupInterval: cleanupInterval, + stopCleanup: make(chan struct{}), + hits: hits, + misses: misses, + evictions: evictions, + size: size, + } + + // Start background cleanup + go cache.cleanupExpired() + + return cache +} + +// Get retrieves a value from the cache +func (c *Cache[T]) Get(key string) (T, bool) { + ctx := context.Background() + _, span := telemetry.StartSpan(ctx, "cache.get", + attribute.String("cache.name", c.name), + attribute.String("cache.key", key), + ) + defer span.End() + + c.mu.RLock() + defer c.mu.RUnlock() + + entry, exists := c.data[key] + if !exists { + var zero T + c.recordMiss(key) + telemetry.AddEvent(span, "cache.miss", + attribute.String("cache.result", "miss"), + ) + span.SetAttributes(attribute.String("cache.result", "miss")) + return zero, false + } + + if entry.IsExpired() { + var zero T + c.recordMiss(key) + telemetry.AddEvent(span, "cache.miss", + attribute.String("cache.result", "miss"), + attribute.String("cache.miss_reason", "expired"), + ) + span.SetAttributes( + attribute.String("cache.result", "miss"), + attribute.String("cache.miss_reason", "expired"), + ) + return zero, false + } + + // Update access time and count + entry.AccessedAt = time.Now() + entry.AccessCount++ + + c.recordHit(key) + telemetry.AddEvent(span, "cache.hit", + attribute.String("cache.result", "hit"), + attribute.Int64("cache.access_count", entry.AccessCount), + ) + span.SetAttributes( + attribute.String("cache.result", "hit"), + attribute.Int64("cache.access_count", entry.AccessCount), + ) + + logger.Get().Debug("Cache hit", "key", key, "access_count", entry.AccessCount) + return entry.Value, true +} + +// Set stores a value in the cache with default TTL +func (c *Cache[T]) Set(key string, value T) { + c.SetWithTTL(key, value, c.defaultTTL) +} + +// SetWithTTL stores a value in the cache with specified TTL +func (c *Cache[T]) SetWithTTL(key string, value T, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + + // Check if we need to evict items to make room + if len(c.data) >= c.maxSize { + c.evictLRU() + } + + entry := &CacheEntry[T]{ + Value: value, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + AccessedAt: now, + AccessCount: 1, + } + + // Check if key already exists + if _, exists := c.data[key]; !exists { + c.size.Add(context.Background(), 1) + } + + c.data[key] = entry + + logger.Get().Debug("Cache set", "key", key, "ttl", ttl) +} + +// Delete removes a value from the cache +func (c *Cache[T]) Delete(key string) { + c.mu.Lock() + defer c.mu.Unlock() + + if _, exists := c.data[key]; exists { + delete(c.data, key) + c.size.Add(context.Background(), -1) + logger.Get().Debug("Cache delete", "key", key) + } +} + +// Clear removes all items from the cache +func (c *Cache[T]) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + + count := len(c.data) + c.data = make(map[string]*CacheEntry[T]) + c.size.Add(context.Background(), -int64(count)) + + logger.Get().Info("Cache cleared", "items_removed", count) +} + +// Size returns the current number of items in the cache +func (c *Cache[T]) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.data) +} + +// Name returns the name of the cache +func (c *Cache[T]) Name() string { + return c.name +} + +// Stats returns cache statistics +func (c *Cache[T]) Stats() CacheStats { + c.mu.RLock() + defer c.mu.RUnlock() + + stats := CacheStats{ + Size: len(c.data), + MaxSize: c.maxSize, + Expired: 0, + Oldest: time.Now(), + Newest: time.Time{}, + } + + for _, entry := range c.data { + if entry.IsExpired() { + stats.Expired++ + } + + if entry.CreatedAt.Before(stats.Oldest) { + stats.Oldest = entry.CreatedAt + } + + if entry.CreatedAt.After(stats.Newest) { + stats.Newest = entry.CreatedAt + } + } + + return stats +} + +// CacheStats represents cache statistics +type CacheStats struct { + Size int `json:"size"` + MaxSize int `json:"max_size"` + Expired int `json:"expired"` + Oldest time.Time `json:"oldest"` + Newest time.Time `json:"newest"` +} + +// cleanupExpired removes expired entries from the cache +func (c *Cache[T]) cleanupExpired() { + ticker := time.NewTicker(c.cleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + c.performCleanup() + case <-c.stopCleanup: + return + } + } +} + +// performCleanup removes expired entries +func (c *Cache[T]) performCleanup() { + c.mu.Lock() + defer c.mu.Unlock() + + keysToDelete := make([]string, 0) + + for key, entry := range c.data { + if entry.IsExpired() { + keysToDelete = append(keysToDelete, key) + } + } + + if len(keysToDelete) > 0 { + for _, key := range keysToDelete { + delete(c.data, key) + c.evictions.Add(context.Background(), 1) + } + + c.size.Add(context.Background(), -int64(len(keysToDelete))) + logger.Get().Debug("Cache cleanup", "expired_items", len(keysToDelete)) + } +} + +// evictLRU removes the least recently used item +func (c *Cache[T]) evictLRU() { + var oldestKey string + var oldestTime time.Time = time.Now() + + for key, entry := range c.data { + if entry.AccessedAt.Before(oldestTime) { + oldestTime = entry.AccessedAt + oldestKey = key + } + } + + if oldestKey != "" { + delete(c.data, oldestKey) + c.evictions.Add(context.Background(), 1) + c.size.Add(context.Background(), -1) + logger.Get().Debug("Cache LRU eviction", "key", oldestKey) + } +} + +// recordHit records a cache hit +func (c *Cache[T]) recordHit(key string) { + c.hits.Add(context.Background(), 1, metric.WithAttributes( + attribute.String("cache.key", key), + attribute.String("cache.result", "hit"), + attribute.String("cache.name", c.name), + )) +} + +// recordMiss records a cache miss +func (c *Cache[T]) recordMiss(key string) { + c.misses.Add(context.Background(), 1, metric.WithAttributes( + attribute.String("cache.key", key), + attribute.String("cache.result", "miss"), + attribute.String("cache.name", c.name), + )) +} + +// Close stops the cache cleanup goroutine +func (c *Cache[T]) Close() { + close(c.stopCleanup) +} + +// InvalidateByType clears the entire cache for a specific cache type +func InvalidateByType(cacheType CacheType) { + ctx := context.Background() + _, span := telemetry.StartSpan(ctx, "cache.invalidate", + attribute.String("cache.type", cacheType.String()), + attribute.String("cache.operation", "invalidate"), + ) + defer span.End() + + InitCaches() + if cache, exists := cacheRegistry[cacheType]; exists { + oldSize := cache.Size() + cache.Clear() + + telemetry.AddEvent(span, "cache.invalidated", + attribute.String("cache.name", cache.name), + attribute.Int("cache.items_cleared", oldSize), + ) + span.SetAttributes( + attribute.String("cache.name", cache.name), + attribute.Int("cache.items_cleared", oldSize), + ) + telemetry.RecordSuccess(span, "Cache invalidated successfully") + + logger.Get().Info("Cache invalidated", "cache_type", cacheType.String(), "reason", "modification_command", "items_cleared", oldSize) + } else { + telemetry.RecordError(span, fmt.Errorf("cache type not found: %s", cacheType.String()), "Cache type not found") + } +} + +// InvalidateKubernetesCache clears the Kubernetes cache +func InvalidateKubernetesCache() { + InvalidateByType(CacheTypeKubernetes) +} + +// InvalidateHelmCache clears the Helm cache +func InvalidateHelmCache() { + InvalidateByType(CacheTypeHelm) +} + +// InvalidateIstioCache clears the Istio cache +func InvalidateIstioCache() { + InvalidateByType(CacheTypeIstio) +} + +// InvalidateCommandCache clears the Command cache +func InvalidateCommandCache() { + InvalidateByType(CacheTypeCommand) +} + +// InvalidateCacheForCommand invalidates the appropriate cache based on command type +func InvalidateCacheForCommand(command string) { + if cacheType, exists := commandToCacheType[command]; exists { + InvalidateByType(cacheType) + } else { + // Default to command cache for unknown commands + InvalidateCommandCache() + } +} + +// Global cache instances for different use cases +var ( + // cacheRegistry holds all cache instances by type + cacheRegistry = make(map[CacheType]*Cache[string]) + once sync.Once +) + +// InitCaches initializes all global cache instances +func InitCaches() { + once.Do(func() { + // Initialize caches with optimized TTL values based on use case + // Kubernetes: 45s - K8s resources change frequently, users expect fresh data + cacheRegistry[CacheTypeKubernetes] = NewCache[string](CacheTypeKubernetes.String(), 45*time.Second, 1000, 1*time.Minute) + + // Istio: 1m - Service mesh config more stable than pods, but proxy status can change + cacheRegistry[CacheTypeIstio] = NewCache[string](CacheTypeIstio.String(), 1*time.Minute, 500, 1*time.Minute) + + // Helm: 2m - Releases change less frequently, chart info is stable + cacheRegistry[CacheTypeHelm] = NewCache[string](CacheTypeHelm.String(), 2*time.Minute, 300, 2*time.Minute) + + // Command: 3m - General CLI commands have stable output, status commands don't change rapidly + cacheRegistry[CacheTypeCommand] = NewCache[string](CacheTypeCommand.String(), 3*time.Minute, 200, 1*time.Minute) + + logger.Get().Info("Caches initialized") + }) +} + +// GetCacheByType returns a cache instance by cache type +func GetCacheByType(cacheType CacheType) *Cache[string] { + InitCaches() + if cache, exists := cacheRegistry[cacheType]; exists { + return cache + } + // Fallback to command cache if type not found + return cacheRegistry[CacheTypeCommand] +} + +// GetCacheByCommand returns a cache instance based on the command name +func GetCacheByCommand(command string) *Cache[string] { + InitCaches() + if cacheType, exists := commandToCacheType[command]; exists { + return GetCacheByType(cacheType) + } + // Default to command cache for unknown commands + return GetCacheByType(CacheTypeCommand) +} + +// CacheKey generates a consistent cache key from components +func CacheKey(components ...string) string { + result := "" + for i, component := range components { + if i > 0 { + result += ":" + } + result += component + } + return result +} + +// CacheResult is a helper function to cache the result of a function +func CacheResult[T any](cache *Cache[T], key string, ttl time.Duration, fn func() (T, error)) (T, error) { + ctx := context.Background() + _, span := telemetry.StartSpan(ctx, "cache.result", + attribute.String("cache.name", cache.name), + attribute.String("cache.key", key), + attribute.String("cache.ttl", ttl.String()), + ) + defer span.End() + + var zero T + + // Try to get from cache first + if cachedResult, found := cache.Get(key); found { + telemetry.AddEvent(span, "cache.result.hit", + attribute.String("cache.operation", "get"), + attribute.String("cache.result", "hit"), + ) + span.SetAttributes( + attribute.String("cache.operation", "get"), + attribute.String("cache.result", "hit"), + ) + telemetry.RecordSuccess(span, "Cache hit - returning cached result") + return cachedResult, nil + } + + // Not in cache, execute function + telemetry.AddEvent(span, "cache.result.miss", + attribute.String("cache.operation", "compute"), + attribute.String("cache.result", "miss"), + ) + span.SetAttributes( + attribute.String("cache.operation", "compute"), + attribute.String("cache.result", "miss"), + ) + + result, err := fn() + if err != nil { + telemetry.RecordError(span, err, "Function execution failed") + return zero, err + } + + // Store in cache + cache.SetWithTTL(key, result, ttl) + + telemetry.AddEvent(span, "cache.result.stored", + attribute.String("cache.operation", "set"), + ) + span.SetAttributes(attribute.String("cache.operation", "set")) + telemetry.RecordSuccess(span, "Function executed and result cached") + + return result, nil +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 00000000..cc7cf641 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,488 @@ +package cache + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNewCache(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 100, 10*time.Second) + + if cache.defaultTTL != 1*time.Minute { + t.Errorf("Expected default TTL of 1 minute, got %v", cache.defaultTTL) + } + + if cache.maxSize != 100 { + t.Errorf("Expected max size of 100, got %d", cache.maxSize) + } + + if cache.cleanupInterval != 10*time.Second { + t.Errorf("Expected cleanup interval of 10 seconds, got %v", cache.cleanupInterval) + } + + if cache.name != "test-cache" { + t.Errorf("Expected cache name 'test-cache', got %s", cache.name) + } + + cache.Close() +} + +func TestCacheName(t *testing.T) { + cache := NewCache[string]("my-test-cache", 1*time.Minute, 100, 10*time.Second) + defer cache.Close() + + if cache.Name() != "my-test-cache" { + t.Errorf("Expected cache name 'my-test-cache', got %s", cache.Name()) + } +} + +func TestCacheSetAndGet(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 100, 10*time.Second) + defer cache.Close() + + // Test set and get + cache.Set("key1", "value1") + value, found := cache.Get("key1") + + if !found { + t.Error("Expected to find key1") + } + + if value != "value1" { + t.Errorf("Expected value1, got %v", value) + } +} + +func TestCacheSetWithTTL(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 100, 10*time.Second) + defer cache.Close() + + // Test set with custom TTL + cache.SetWithTTL("key1", "value1", 100*time.Millisecond) + + // Should be found immediately + value, found := cache.Get("key1") + if !found { + t.Error("Expected to find key1") + } + if value != "value1" { + t.Errorf("Expected value1, got %v", value) + } + + // Wait for expiration + time.Sleep(150 * time.Millisecond) + + // Should not be found after expiration + _, found = cache.Get("key1") + if found { + t.Error("Expected key1 to be expired") + } +} + +func TestCacheDelete(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 100, 10*time.Second) + defer cache.Close() + + cache.Set("key1", "value1") + cache.Delete("key1") + + _, found := cache.Get("key1") + if found { + t.Error("Expected key1 to be deleted") + } +} + +func TestCacheClear(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 100, 10*time.Second) + defer cache.Close() + + cache.Set("key1", "value1") + cache.Set("key2", "value2") + + if cache.Size() != 2 { + t.Errorf("Expected size 2, got %d", cache.Size()) + } + + cache.Clear() + + if cache.Size() != 0 { + t.Errorf("Expected size 0 after clear, got %d", cache.Size()) + } +} + +func TestCacheEviction(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 2, 10*time.Second) // Small cache + defer cache.Close() + + // Fill cache to capacity + cache.Set("key1", "value1") + cache.Set("key2", "value2") + + // Add one more item - should evict LRU + cache.Set("key3", "value3") + + // key1 should be evicted (oldest) + _, found := cache.Get("key1") + if found { + t.Error("Expected key1 to be evicted") + } + + // key2 and key3 should still be there + _, found = cache.Get("key2") + if !found { + t.Error("Expected key2 to be present") + } + + _, found = cache.Get("key3") + if !found { + t.Error("Expected key3 to be present") + } +} + +func TestCacheExpiration(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 100, 50*time.Millisecond) // Fast cleanup + defer cache.Close() + + // Set item with short TTL + cache.SetWithTTL("key1", "value1", 100*time.Millisecond) + + // Wait for cleanup to run + time.Sleep(200 * time.Millisecond) + + // Item should be cleaned up + _, found := cache.Get("key1") + if found { + t.Error("Expected key1 to be cleaned up") + } +} + +func TestCacheStats(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 100, 10*time.Second) + defer cache.Close() + + cache.Set("key1", "value1") + cache.Set("key2", "value2") + + stats := cache.Stats() + + if stats.Size != 2 { + t.Errorf("Expected stats size 2, got %d", stats.Size) + } + + if stats.MaxSize != 100 { + t.Errorf("Expected stats max size 100, got %d", stats.MaxSize) + } + + if stats.Expired != 0 { + t.Errorf("Expected 0 expired items, got %d", stats.Expired) + } +} + +func TestCacheKey(t *testing.T) { + tests := []struct { + name string + components []string + expected string + }{ + {"single component", []string{"key1"}, "key1"}, + {"multiple components", []string{"key1", "key2", "key3"}, "key1:key2:key3"}, + {"empty components", []string{}, ""}, + {"empty string component", []string{"key1", "", "key3"}, "key1::key3"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := CacheKey(tt.components...) + if result != tt.expected { + t.Errorf("Expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestCacheResult(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 100, 10*time.Second) + defer cache.Close() + + callCount := 0 + testFunction := func() (string, error) { + callCount++ + return "result", nil + } + + // First call should execute function + result, err := CacheResult(cache, "test-key", 1*time.Minute, testFunction) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result != "result" { + t.Errorf("Expected 'result', got %q", result) + } + if callCount != 1 { + t.Errorf("Expected function to be called once, got %d", callCount) + } + + // Second call should use cache + result, err = CacheResult(cache, "test-key", 1*time.Minute, testFunction) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result != "result" { + t.Errorf("Expected 'result', got %q", result) + } + if callCount != 1 { + t.Errorf("Expected function to be called once (cached), got %d", callCount) + } +} + +func TestCacheResultWithError(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 100, 10*time.Second) + defer cache.Close() + + testFunction := func() (string, error) { + return "", &testError{message: "test error"} + } + + result, err := CacheResult(cache, "test-key", 1*time.Minute, testFunction) + if err == nil { + t.Error("Expected error") + } + if result != "" { + t.Errorf("Expected empty result, got %q", result) + } + + // Check that error result is not cached + _, found := cache.Get("test-key") + if found { + t.Error("Expected error result not to be cached") + } +} + +func TestCacheInitialization(t *testing.T) { + // Test that all cache types are properly initialized + types := []CacheType{ + CacheTypeKubernetes, + CacheTypeCommand, + CacheTypeHelm, + CacheTypeIstio, + } + + for _, cacheType := range types { + t.Run(cacheType.String(), func(t *testing.T) { + cache := GetCacheByType(cacheType) + if cache == nil { + t.Errorf("Expected cache for type %s to be initialized", cacheType.String()) + } + if cache.Name() != cacheType.String() { + t.Errorf("Expected cache name %s, got %s", cacheType.String(), cache.Name()) + } + }) + } +} + +func TestCacheEntry(t *testing.T) { + now := time.Now() + entry := &CacheEntry[string]{ + Value: "test", + CreatedAt: now, + ExpiresAt: now.Add(1 * time.Minute), + AccessedAt: now, + AccessCount: 1, + } + + // Should not be expired + if entry.IsExpired() { + t.Error("Expected entry not to be expired") + } + + // Make it expired + entry.ExpiresAt = now.Add(-1 * time.Minute) + + // Should be expired + if !entry.IsExpired() { + t.Error("Expected entry to be expired") + } +} + +func TestCachePerformCleanup(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 100, 10*time.Second) + defer cache.Close() + + // Add expired item + cache.SetWithTTL("expired", "value", -1*time.Minute) + + // Add valid item + cache.Set("valid", "value") + + // Perform cleanup + cache.performCleanup() + + // Expired item should be removed + _, found := cache.Get("expired") + if found { + t.Error("Expected expired item to be removed") + } + + // Valid item should remain + _, found = cache.Get("valid") + if !found { + t.Error("Expected valid item to remain") + } +} + +func TestCacheConcurrency(t *testing.T) { + cache := NewCache[string]("test-cache", 1*time.Minute, 1000, 10*time.Second) + defer cache.Close() + + // Test concurrent operations + done := make(chan bool) + + // Writer goroutine + go func() { + for i := 0; i < 100; i++ { + cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i)) + } + done <- true + }() + + // Reader goroutine + go func() { + for i := 0; i < 100; i++ { + cache.Get(fmt.Sprintf("key%d", i)) + } + done <- true + }() + + // Wait for both goroutines + <-done + <-done + + // Cache should have items + if cache.Size() == 0 { + t.Error("Expected cache to have items") + } +} + +// Helper types for testing +type testError struct { + message string +} + +func (e *testError) Error() string { + return e.message +} + +func TestCacheTypeString(t *testing.T) { + tests := []struct { + cacheType CacheType + expected string + }{ + {CacheTypeKubernetes, "kubernetes"}, + {CacheTypeCommand, "command"}, + {CacheTypeHelm, "helm"}, + {CacheTypeIstio, "istio"}, + {CacheType(999), "unknown"}, // Test unknown type + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.cacheType.String() + if result != tt.expected { + t.Errorf("Expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestGetCacheByType(t *testing.T) { + // Test all valid cache types + types := []CacheType{ + CacheTypeKubernetes, + CacheTypeCommand, + CacheTypeHelm, + CacheTypeIstio, + } + + for _, cacheType := range types { + t.Run(cacheType.String(), func(t *testing.T) { + cache := GetCacheByType(cacheType) + if cache == nil { + t.Errorf("Expected cache for type %s, got nil", cacheType.String()) + } + if cache.Name() != cacheType.String() { + t.Errorf("Expected cache name %s, got %s", cacheType.String(), cache.Name()) + } + }) + } +} + +func TestGetCacheByCommand(t *testing.T) { + tests := []struct { + command string + expectedType CacheType + }{ + {"kubectl", CacheTypeKubernetes}, + {"helm", CacheTypeHelm}, + {"istioctl", CacheTypeIstio}, + {"cilium", CacheTypeCommand}, + {"argo", CacheTypeCommand}, + {"unknown-command", CacheTypeCommand}, // Should default to command cache + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + cache := GetCacheByCommand(tt.command) + if cache == nil { + t.Errorf("Expected cache for command %s, got nil", tt.command) + } + if cache.Name() != tt.expectedType.String() { + t.Errorf("Expected cache name %s for command %s, got %s", + tt.expectedType.String(), tt.command, cache.Name()) + } + }) + } +} + +func TestCacheOTelTracing(t *testing.T) { + // This test verifies that OTEL tracing calls don't panic + // The actual tracing verification would require setting up an OTEL test environment + cache := NewCache[string]("test-tracing", 1*time.Minute, 10, 5*time.Minute) + defer cache.Close() + + // Test cache miss with tracing + _, found := cache.Get("missing-key") + assert.False(t, found) + + // Test cache hit with tracing + cache.Set("test-key", "test-value") + value, found := cache.Get("test-key") + assert.True(t, found) + assert.Equal(t, "test-value", value) + + // Test CacheResult with tracing + callCount := 0 + result, err := CacheResult(cache, "result-key", 1*time.Minute, func() (string, error) { + callCount++ + return "computed-value", nil + }) + assert.NoError(t, err) + assert.Equal(t, "computed-value", result) + assert.Equal(t, 1, callCount) + + // Test cache hit on second call + result2, err := CacheResult(cache, "result-key", 1*time.Minute, func() (string, error) { + callCount++ + return "computed-value", nil + }) + assert.NoError(t, err) + assert.Equal(t, "computed-value", result2) + assert.Equal(t, 1, callCount) // Should not increment due to cache hit + + // Test cache invalidation with tracing + oldSize := cache.Size() + InvalidateByType(CacheTypeCommand) + assert.True(t, oldSize > 0) // Verify we had items to clear +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go new file mode 100644 index 00000000..0fa2741f --- /dev/null +++ b/internal/cmd/cmd.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "context" + "os/exec" + "time" + + "github.com/kagent-dev/tools/internal/logger" +) + +// ShellExecutor defines the interface for executing shell commands +type ShellExecutor interface { + Exec(ctx context.Context, command string, args ...string) (output []byte, err error) +} + +// DefaultShellExecutor implements ShellExecutor using os/exec +type DefaultShellExecutor struct{} + +// Exec executes a command using os/exec.CommandContext +func (e *DefaultShellExecutor) Exec(ctx context.Context, command string, args ...string) ([]byte, error) { + log := logger.WithContext(ctx) + startTime := time.Now() + redactedArgs := logger.RedactArgsForLog(args) + + log.Info("executing command", + "command", command, + "args", redactedArgs, + ) + + cmd := exec.CommandContext(ctx, command, args...) + output, err := cmd.CombinedOutput() + + duration := time.Since(startTime) + + if err != nil { + log.Error("command execution failed", + "command", command, + "args", redactedArgs, + "error", err, + "output", string(output), + "duration", duration.Seconds(), + ) + } else { + log.Info("command execution successful", + "command", command, + "args", redactedArgs, + "duration", duration.Seconds(), + ) + } + + return output, err +} + +// Context key for shell executor injection +type contextKey string + +const shellExecutorKey contextKey = "shellExecutor" + +// WithShellExecutor returns a context with the given shell executor +func WithShellExecutor(ctx context.Context, executor ShellExecutor) context.Context { + return context.WithValue(ctx, shellExecutorKey, executor) +} + +// GetShellExecutor retrieves the shell executor from context, or returns default +func GetShellExecutor(ctx context.Context) ShellExecutor { + if executor, ok := ctx.Value(shellExecutorKey).(ShellExecutor); ok { + return executor + } + return &DefaultShellExecutor{} +} diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go new file mode 100644 index 00000000..f902d4c4 --- /dev/null +++ b/internal/cmd/cmd_test.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultShellExecutor(t *testing.T) { + executor := &DefaultShellExecutor{} + + // Test successful command + output, err := executor.Exec(context.Background(), "echo", "hello") + assert.NoError(t, err) + assert.Equal(t, "hello\n", string(output)) + + // Test command with error + _, err = executor.Exec(context.Background(), "nonexistent-command") + assert.Error(t, err) +} + +func TestMockShellExecutor(t *testing.T) { + mock := NewMockShellExecutor() + + t.Run("unmocked command returns error", func(t *testing.T) { + _, err := mock.Exec(context.Background(), "unmocked", "command") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no mock found for command") + }) + + t.Run("mocked command returns expected result", func(t *testing.T) { + expectedOutput := "mocked output" + mock.AddCommandString("kubectl", []string{"get", "pods"}, expectedOutput, nil) + + output, err := mock.Exec(context.Background(), "kubectl", "get", "pods") + assert.NoError(t, err) + assert.Equal(t, expectedOutput, string(output)) + }) +} + +func TestContextShellExecutor(t *testing.T) { + t.Run("default executor when no context value", func(t *testing.T) { + ctx := context.Background() + executor := GetShellExecutor(ctx) + + _, ok := executor.(*DefaultShellExecutor) + assert.True(t, ok, "should return DefaultShellExecutor when no context value") + }) + + t.Run("mock executor from context", func(t *testing.T) { + mock := NewMockShellExecutor() + ctx := WithShellExecutor(context.Background(), mock) + + executor := GetShellExecutor(ctx) + assert.Equal(t, mock, executor, "should return the mock executor from context") + }) +} diff --git a/internal/cmd/mock.go b/internal/cmd/mock.go new file mode 100644 index 00000000..3f13c47b --- /dev/null +++ b/internal/cmd/mock.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "sync" +) + +// MockCall represents a recorded command execution for testing +type MockCall struct { + Command string + Args []string +} + +// MockShellExecutor is a mock implementation of ShellExecutor for testing +type MockShellExecutor struct { + mu sync.Mutex + callLog []MockCall + commandMocks map[string]map[string]struct { + output string + err error + } + partialMatchers []struct { + command string + args []string + output string + err error + } +} + +// NewMockShellExecutor creates a new mock shell executor +func NewMockShellExecutor() *MockShellExecutor { + return &MockShellExecutor{ + commandMocks: make(map[string]map[string]struct { + output string + err error + }), + } +} + +// AddCommandString mocks a command with specific arguments and a string output +func (m *MockShellExecutor) AddCommandString(command string, args []string, output string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + argsKey := strings.Join(args, " ") + if _, ok := m.commandMocks[command]; !ok { + m.commandMocks[command] = make(map[string]struct { + output string + err error + }) + } + m.commandMocks[command][argsKey] = struct { + output string + err error + }{output, err} +} + +// AddPartialMatcherString mocks a command with partial argument matching +func (m *MockShellExecutor) AddPartialMatcherString(command string, args []string, output string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.partialMatchers = append(m.partialMatchers, struct { + command string + args []string + output string + err error + }{command, args, output, err}) +} + +// Exec records the call and returns a mocked output or error +func (m *MockShellExecutor) Exec(ctx context.Context, command string, args ...string) ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.callLog = append(m.callLog, MockCall{Command: command, Args: args}) + + // Check for exact match first + argsKey := strings.Join(args, " ") + if mocks, ok := m.commandMocks[command]; ok { + if mock, ok := mocks[argsKey]; ok { + return []byte(mock.output), mock.err + } + } + + // Check for partial match + for _, matcher := range m.partialMatchers { + if matcher.command == command && argsContain(args, matcher.args) { + return []byte(matcher.output), matcher.err + } + } + + return nil, fmt.Errorf("no mock found for command: %s %v", command, args) +} + +// GetCallLog returns the history of commands executed +func (m *MockShellExecutor) GetCallLog() []MockCall { + m.mu.Lock() + defer m.mu.Unlock() + return m.callLog +} + +// argsContain checks if all elements of subset are in set +func argsContain(set, subset []string) bool { + for _, sub := range subset { + found := false + for _, s := range set { + if strings.Contains(s, sub) { + found = true + break + } + } + if !found { + return false + } + } + return true +} diff --git a/internal/commands/builder.go b/internal/commands/builder.go new file mode 100644 index 00000000..c9baed83 --- /dev/null +++ b/internal/commands/builder.go @@ -0,0 +1,722 @@ +package commands + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/kagent-dev/tools/internal/cache" + "github.com/kagent-dev/tools/internal/cmd" + "github.com/kagent-dev/tools/internal/errors" + "github.com/kagent-dev/tools/internal/logger" + "github.com/kagent-dev/tools/internal/security" + "github.com/kagent-dev/tools/internal/telemetry" + "go.opentelemetry.io/otel/attribute" +) + +const ( + // DefaultTimeout is the default timeout for command execution + DefaultTimeout = 2 * time.Minute + // DefaultCacheTTL is the default cache TTL + DefaultCacheTTL = 1 * time.Minute +) + +// CommandBuilder provides a fluent interface for building CLI commands +type CommandBuilder struct { + command string + args []string + namespace string + context string + kubeconfig string + token string + output string + labels map[string]string + annotations map[string]string + timeout time.Duration + useTimeout bool + dryRun bool + force bool + wait bool + validate bool + cached bool + cacheTTL time.Duration + cacheKey string +} + +// NewCommandBuilder creates a new command builder +func NewCommandBuilder(command string) *CommandBuilder { + return &CommandBuilder{ + command: command, + args: make([]string, 0), + labels: make(map[string]string), + annotations: make(map[string]string), + timeout: DefaultTimeout, + useTimeout: false, // Only enable timeout when explicitly requested + validate: true, + cacheTTL: DefaultCacheTTL, + } +} + +// KubectlBuilder creates a kubectl command builder +func KubectlBuilder() *CommandBuilder { + return NewCommandBuilder("kubectl") +} + +// HelmBuilder creates a helm command builder +func HelmBuilder() *CommandBuilder { + return NewCommandBuilder("helm") +} + +// IstioCtlBuilder creates an istioctl command builder +func IstioCtlBuilder() *CommandBuilder { + return NewCommandBuilder("istioctl") +} + +// CiliumBuilder creates a cilium command builder +func CiliumBuilder() *CommandBuilder { + return NewCommandBuilder("cilium") +} + +// ArgoRolloutsBuilder creates an argo rollouts command builder +func ArgoRolloutsBuilder() *CommandBuilder { + return NewCommandBuilder("kubectl").WithArgs("argo", "rollouts") +} + +// WithArgs adds arguments to the command +func (cb *CommandBuilder) WithArgs(args ...string) *CommandBuilder { + cb.args = append(cb.args, args...) + return cb +} + +// WithNamespace sets the namespace +func (cb *CommandBuilder) WithNamespace(namespace string) *CommandBuilder { + if err := security.ValidateNamespace(namespace); err != nil { + logger.Get().Error("Invalid namespace", "namespace", namespace, "error", err) + return cb + } + cb.namespace = namespace + return cb +} + +// WithContext sets the Kubernetes context +func (cb *CommandBuilder) WithContext(context string) *CommandBuilder { + if err := security.ValidateCommandInput(context); err != nil { + logger.Get().Error("Invalid context", "context", context, "error", err) + return cb + } + cb.context = context + return cb +} + +// WithKubeconfig sets the kubeconfig file +func (cb *CommandBuilder) WithKubeconfig(kubeconfig string) *CommandBuilder { + if kubeconfig != "" { + if err := security.ValidateFilePath(kubeconfig); err != nil { + logger.Get().Error("Invalid kubeconfig path", "kubeconfig", kubeconfig, "error", err) + return cb + } + cb.kubeconfig = kubeconfig + } + return cb +} + +// WithToken sets the authentication token for kubectl commands +func (cb *CommandBuilder) WithToken(token string) *CommandBuilder { + if token != "" { + cb.token = token + } + return cb +} + +// WithOutput sets the output format +func (cb *CommandBuilder) WithOutput(output string) *CommandBuilder { + validOutputs := []string{"json", "yaml", "wide", "name", "custom-columns", "custom-columns-file", "go-template", "go-template-file", "jsonpath", "jsonpath-file"} + + valid := false + for _, validOutput := range validOutputs { + if output == validOutput { + valid = true + break + } + } + + if !valid { + logger.Get().Error("Invalid output format", "output", output) + return cb + } + + cb.output = output + return cb +} + +// WithLabel adds a label selector +func (cb *CommandBuilder) WithLabel(key, value string) *CommandBuilder { + if err := security.ValidateK8sLabel(key, value); err != nil { + logger.Get().Error("Invalid label", "key", key, "value", value, "error", err) + return cb + } + cb.labels[key] = value + return cb +} + +// WithLabels adds multiple label selectors +func (cb *CommandBuilder) WithLabels(labels map[string]string) *CommandBuilder { + for key, value := range labels { + cb.WithLabel(key, value) + } + return cb +} + +// WithAnnotation adds an annotation +func (cb *CommandBuilder) WithAnnotation(key, value string) *CommandBuilder { + if err := security.ValidateK8sLabel(key, value); err != nil { + logger.Get().Error("Invalid annotation", "key", key, "value", value, "error", err) + return cb + } + cb.annotations[key] = value + return cb +} + +// WithTimeout sets the command timeout +func (cb *CommandBuilder) WithTimeout(timeout time.Duration) *CommandBuilder { + cb.useTimeout = true + cb.timeout = timeout + return cb +} + +// WithDryRun enables dry run mode +func (cb *CommandBuilder) WithDryRun(dryRun bool) *CommandBuilder { + cb.dryRun = dryRun + return cb +} + +// WithForce enables force mode +func (cb *CommandBuilder) WithForce(force bool) *CommandBuilder { + cb.force = force + return cb +} + +// WithWait enables wait mode +func (cb *CommandBuilder) WithWait(wait bool) *CommandBuilder { + cb.wait = wait + return cb +} + +// WithValidation enables/disables validation +func (cb *CommandBuilder) WithValidation(validate bool) *CommandBuilder { + cb.validate = validate + return cb +} + +// WithCache enables caching of the command result +func (cb *CommandBuilder) WithCache(cached bool) *CommandBuilder { + cb.cached = cached + return cb +} + +// WithCacheTTL sets the cache TTL +func (cb *CommandBuilder) WithCacheTTL(ttl time.Duration) *CommandBuilder { + cb.cacheTTL = ttl + return cb +} + +// WithCacheKey sets a custom cache key +func (cb *CommandBuilder) WithCacheKey(key string) *CommandBuilder { + cb.cacheKey = key + return cb +} + +// Build constructs the final command arguments +func (cb *CommandBuilder) Build() (string, []string, error) { + args := make([]string, 0, len(cb.args)+20) + + // Add main arguments + args = append(args, cb.args...) + + // Add namespace if specified + if cb.namespace != "" { + args = append(args, "--namespace", cb.namespace) + } + + // Add context if specified + if cb.context != "" { + args = append(args, "--context", cb.context) + } + + // Add kubeconfig if specified + if cb.kubeconfig != "" { + args = append(args, "--kubeconfig", cb.kubeconfig) + } + + // Add token if specified + if cb.token != "" { + args = append(args, "--token", cb.token) + } + + // Add output format + if cb.output != "" { + args = append(args, "--output", cb.output) + } + + // Add label selectors + if len(cb.labels) > 0 { + var labelSelectors []string + for key, value := range cb.labels { + if value != "" { + labelSelectors = append(labelSelectors, fmt.Sprintf("%s=%s", key, value)) + } else { + labelSelectors = append(labelSelectors, key) + } + } + if len(labelSelectors) > 0 { + args = append(args, "--selector", strings.Join(labelSelectors, ",")) + } + } + + // Add timeout when explicitly requested + if cb.timeout > 0 && cb.useTimeout { + args = append(args, "--timeout", cb.timeout.String()) + } + + // Add dry run + if cb.dryRun { + args = append(args, "--dry-run=client") + } + + // Add force + if cb.force { + args = append(args, "--force") + } + + // Add wait + if cb.wait { + args = append(args, "--wait") + } + + // Add validation + if !cb.validate { + args = append(args, "--validate=false") + } + + return cb.command, args, nil +} + +// Execute runs the command +func (cb *CommandBuilder) Execute(ctx context.Context) (string, error) { + log := logger.WithContext(ctx) + _, span := telemetry.StartSpan(ctx, "commands.execute", + attribute.String("command", cb.command), + attribute.StringSlice("args", logger.RedactArgsForLog(cb.args)), + attribute.Bool("cached", cb.cached), + ) + defer span.End() + + command, args, err := cb.Build() + if err != nil { + telemetry.RecordError(span, err, "Command build failed") + log.Error("failed to build command", + "command", cb.command, + "error", err, + ) + return "", err + } + + redactedArgs := logger.RedactArgsForLog(args) + span.SetAttributes( + attribute.String("built_command", command), + attribute.StringSlice("built_args", redactedArgs), + ) + + log.Debug("executing command", + "command", command, + "args", redactedArgs, + "cached", cb.cached, + ) + + // Generate cache key if caching is enabled + if cb.cached { + telemetry.AddEvent(span, "execution.cached") + return cb.executeWithCache(ctx, command, args) + } + + // Execute the command + telemetry.AddEvent(span, "execution.direct") + result, err := cb.executeCommand(ctx, command, args) + if err != nil { + telemetry.RecordError(span, err, "Command execution failed") + return "", err + } + + telemetry.RecordSuccess(span, "Command executed successfully") + span.SetAttributes( + attribute.Int("result_length", len(result)), + ) + + return result, nil +} + +func (cb *CommandBuilder) executeWithCache(ctx context.Context, command string, args []string) (string, error) { + log := logger.WithContext(ctx) + redactedArgs := logger.RedactArgsForLog(args) + _, span := telemetry.StartSpan(ctx, "commands.executeWithCache", + attribute.String("command", command), + attribute.StringSlice("args", redactedArgs), + attribute.Bool("cached", true), + ) + defer span.End() + + cacheKey := cb.cacheKey + if cacheKey == "" { + cacheKey = cache.CacheKey(append([]string{command}, args...)...) + } + + log.Info("executing cached command", + "command", command, + "args", redactedArgs, + "cache_key", cacheKey, + "cache_ttl", cb.cacheTTL.String(), + ) + + // Try to get from cache first + cacheInstance := cache.GetCacheByCommand(command) + + telemetry.AddEvent(span, "cache.lookup", + attribute.String("cache_key", cacheKey), + attribute.String("cache_ttl", cb.cacheTTL.String()), + ) + + result, err := cache.CacheResult(cacheInstance, cacheKey, cb.cacheTTL, func() (string, error) { + telemetry.AddEvent(span, "cache.miss.executing_command") + log.Debug("cache miss, executing command", + "command", command, + "args", redactedArgs, + ) + return cb.executeCommand(ctx, command, args) + }) + + if err != nil { + telemetry.RecordError(span, err, "Cached command execution failed") + log.Error("cached command execution failed", + "command", command, + "args", redactedArgs, + "cache_key", cacheKey, + "error", err, + ) + return "", err + } + + telemetry.RecordSuccess(span, "Cached command executed successfully") + log.Info("cached command execution successful", + "command", command, + "args", redactedArgs, + "cache_key", cacheKey, + "result_length", len(result), + ) + + span.SetAttributes( + attribute.String("cache_key", cacheKey), + attribute.Int("result_length", len(result)), + ) + + return result, nil +} + +// executeCommand executes the actual command +func (cb *CommandBuilder) executeCommand(ctx context.Context, command string, args []string) (string, error) { + executor := cmd.GetShellExecutor(ctx) + output, err := executor.Exec(ctx, command, args...) + if err != nil { + // Create appropriate error based on command type + var toolError *errors.ToolError + switch command { + case "kubectl": + toolError = errors.NewKubernetesError(strings.Join(args, " "), err) + case "helm": + toolError = errors.NewHelmError(strings.Join(args, " "), err) + case "istioctl": + toolError = errors.NewIstioError(strings.Join(args, " "), err) + case "cilium": + toolError = errors.NewCiliumError(strings.Join(args, " "), err) + default: + toolError = errors.NewCommandError(command, err) + } + return string(output), toolError + } + + return string(output), nil +} + +// Common command patterns as helper functions + +// GetPods creates a command to get pods +func GetPods(namespace string, labels map[string]string) *CommandBuilder { + builder := KubectlBuilder().WithArgs("get", "pods") + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + if len(labels) > 0 { + builder = builder.WithLabels(labels) + } + + return builder.WithCache(true) +} + +// GetServices creates a command to get services +func GetServices(namespace string, labels map[string]string) *CommandBuilder { + builder := KubectlBuilder().WithArgs("get", "services") + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + if len(labels) > 0 { + builder = builder.WithLabels(labels) + } + + return builder.WithCache(true) +} + +// GetDeployments creates a command to get deployments +func GetDeployments(namespace string, labels map[string]string) *CommandBuilder { + builder := KubectlBuilder().WithArgs("get", "deployments") + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + if len(labels) > 0 { + builder = builder.WithLabels(labels) + } + + return builder.WithCache(true) +} + +// DescribeResource creates a command to describe a resource +func DescribeResource(resourceType, resourceName, namespace string) *CommandBuilder { + builder := KubectlBuilder().WithArgs("describe", resourceType, resourceName) + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + return builder.WithCache(true).WithCacheTTL(2 * time.Minute) +} + +// GetLogs creates a command to get logs +func GetLogs(podName, namespace string, options LogOptions) *CommandBuilder { + builder := KubectlBuilder().WithArgs("logs", podName) + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + if options.Container != "" { + builder = builder.WithArgs("--container", options.Container) + } + + if options.Follow { + builder = builder.WithArgs("--follow") + } + + if options.Previous { + builder = builder.WithArgs("--previous") + } + + if options.Timestamps { + builder = builder.WithArgs("--timestamps") + } + + if options.TailLines > 0 { + builder = builder.WithArgs("--tail", fmt.Sprintf("%d", options.TailLines)) + } + + if options.SinceTime != "" { + builder = builder.WithArgs("--since-time", options.SinceTime) + } + + if options.SinceDuration != "" { + builder = builder.WithArgs("--since", options.SinceDuration) + } + + // Don't cache logs by default as they change frequently + return builder.WithCache(false) +} + +// LogOptions represents options for log commands +type LogOptions struct { + Container string + Follow bool + Previous bool + Timestamps bool + TailLines int + SinceTime string + SinceDuration string +} + +// ApplyResource creates a command to apply a resource +func ApplyResource(filename string, namespace string, options ApplyOptions) *CommandBuilder { + builder := KubectlBuilder().WithArgs("apply", "-f", filename) + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + if options.DryRun { + builder = builder.WithDryRun(true) + } + + if options.Force { + builder = builder.WithForce(true) + } + + if options.Wait { + builder = builder.WithWait(true) + } + + if !options.Validate { + builder = builder.WithValidation(false) + } + + return builder.WithCache(false) // Don't cache apply operations +} + +// ApplyOptions represents options for apply commands +type ApplyOptions struct { + DryRun bool + Force bool + Wait bool + Validate bool +} + +// DeleteResource creates a command to delete a resource +func DeleteResource(resourceType, resourceName, namespace string, options DeleteOptions) *CommandBuilder { + builder := KubectlBuilder().WithArgs("delete", resourceType, resourceName) + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + if options.Force { + builder = builder.WithForce(true) + } + + if options.GracePeriod >= 0 { + builder = builder.WithArgs("--grace-period", fmt.Sprintf("%d", options.GracePeriod)) + } + + if options.Wait { + builder = builder.WithWait(true) + } + + return builder.WithCache(false) // Don't cache delete operations +} + +// DeleteOptions represents options for delete commands +type DeleteOptions struct { + Force bool + GracePeriod int + Wait bool +} + +// HelmInstall creates a command to install a Helm chart +func HelmInstall(releaseName, chart, namespace string, options HelmInstallOptions) *CommandBuilder { + builder := HelmBuilder().WithArgs("install", releaseName, chart) + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + if options.CreateNamespace { + builder = builder.WithArgs("--create-namespace") + } + + if options.DryRun { + builder = builder.WithDryRun(true) + } + + if options.Wait { + builder = builder.WithWait(true) + } + + if options.ValuesFile != "" { + builder = builder.WithArgs("--values", options.ValuesFile) + } + + for key, value := range options.SetValues { + builder = builder.WithArgs("--set", fmt.Sprintf("%s=%s", key, value)) + } + + return builder.WithCache(false) // Don't cache install operations +} + +// HelmInstallOptions represents options for Helm install commands +type HelmInstallOptions struct { + CreateNamespace bool + DryRun bool + Wait bool + ValuesFile string + SetValues map[string]string +} + +// HelmList creates a command to list Helm releases +func HelmList(namespace string, options HelmListOptions) *CommandBuilder { + builder := HelmBuilder().WithArgs("list") + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + if options.AllNamespaces { + builder = builder.WithArgs("--all-namespaces") + } + + if options.Output != "" { + builder = builder.WithOutput(options.Output) + } + + return builder.WithCache(true).WithCacheTTL(2 * time.Minute) +} + +// HelmListOptions represents options for Helm list commands +type HelmListOptions struct { + AllNamespaces bool + Output string +} + +// IstioProxyStatus creates a command to get Istio proxy status +func IstioProxyStatus(podName, namespace string) *CommandBuilder { + builder := IstioCtlBuilder().WithArgs("proxy-status") + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + if podName != "" { + builder = builder.WithArgs(podName) + } + + return builder.WithCache(true).WithCacheTTL(30 * time.Second) +} + +// CiliumStatus creates a command to get Cilium status +func CiliumStatus() *CommandBuilder { + return CiliumBuilder().WithArgs("status").WithCache(true).WithCacheTTL(30 * time.Second) +} + +// ArgoRolloutsGet creates a command to get Argo rollouts +func ArgoRolloutsGet(rolloutName, namespace string) *CommandBuilder { + builder := ArgoRolloutsBuilder().WithArgs("get", "rollout") + + if rolloutName != "" { + builder = builder.WithArgs(rolloutName) + } + + if namespace != "" { + builder = builder.WithNamespace(namespace) + } + + return builder.WithCache(true).WithCacheTTL(1 * time.Minute) +} diff --git a/internal/commands/builder_test.go b/internal/commands/builder_test.go new file mode 100644 index 00000000..f8a98fc2 --- /dev/null +++ b/internal/commands/builder_test.go @@ -0,0 +1,581 @@ +package commands + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCommandBuilder(t *testing.T) { + cb := NewCommandBuilder("test-command") + + assert.Equal(t, "test-command", cb.command) + assert.Empty(t, cb.args) + assert.Empty(t, cb.namespace) + assert.Empty(t, cb.context) + assert.Empty(t, cb.kubeconfig) + assert.Empty(t, cb.output) + assert.NotNil(t, cb.labels) + assert.NotNil(t, cb.annotations) + assert.Equal(t, DefaultTimeout, cb.timeout) + assert.Equal(t, DefaultCacheTTL, cb.cacheTTL) + assert.True(t, cb.validate) + assert.False(t, cb.cached) + assert.False(t, cb.dryRun) + assert.False(t, cb.force) + assert.False(t, cb.wait) +} + +func TestCommandBuilderFactories(t *testing.T) { + tests := []struct { + name string + factory func() *CommandBuilder + expected string + }{ + {"kubectl", KubectlBuilder, "kubectl"}, + {"helm", HelmBuilder, "helm"}, + {"istioctl", IstioCtlBuilder, "istioctl"}, + {"cilium", CiliumBuilder, "cilium"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cb := tt.factory() + assert.Equal(t, tt.expected, cb.command) + }) + } +} + +func TestArgoRolloutsBuilder(t *testing.T) { + cb := ArgoRolloutsBuilder() + + assert.Equal(t, "kubectl", cb.command) + assert.Equal(t, []string{"argo", "rollouts"}, cb.args) +} + +func TestCommandBuilderWithArgs(t *testing.T) { + cb := NewCommandBuilder("test").WithArgs("arg1", "arg2") + + assert.Equal(t, []string{"arg1", "arg2"}, cb.args) + + // Test chaining + cb.WithArgs("arg3") + assert.Equal(t, []string{"arg1", "arg2", "arg3"}, cb.args) +} + +func TestCommandBuilderWithNamespace(t *testing.T) { + cb := NewCommandBuilder("test").WithNamespace("default") + + assert.Equal(t, "default", cb.namespace) + + // Test invalid namespace - should not set the namespace + cb.WithNamespace("invalid..namespace") + assert.Equal(t, "default", cb.namespace) // Should remain unchanged +} + +func TestCommandBuilderWithContext(t *testing.T) { + cb := NewCommandBuilder("test").WithContext("minikube") + + assert.Equal(t, "minikube", cb.context) +} + +func TestCommandBuilderWithKubeconfig(t *testing.T) { + cb := NewCommandBuilder("test").WithKubeconfig("/path/to/config") + + assert.Equal(t, "/path/to/config", cb.kubeconfig) +} + +func TestCommandBuilderWithOutput(t *testing.T) { + validOutputs := []string{"json", "yaml", "wide", "name"} + + for _, output := range validOutputs { + cb := NewCommandBuilder("test").WithOutput(output) + assert.Equal(t, output, cb.output) + } + + // Test invalid output + cb := NewCommandBuilder("test").WithOutput("invalid") + assert.Empty(t, cb.output) +} + +func TestCommandBuilderWithLabel(t *testing.T) { + cb := NewCommandBuilder("test").WithLabel("app", "web") + + assert.Equal(t, "web", cb.labels["app"]) +} + +func TestCommandBuilderWithLabels(t *testing.T) { + labels := map[string]string{ + "app": "web", + "version": "v1.0.0", + } + + cb := NewCommandBuilder("test").WithLabels(labels) + + assert.Equal(t, labels["app"], cb.labels["app"]) + assert.Equal(t, labels["version"], cb.labels["version"]) +} + +func TestCommandBuilderWithAnnotation(t *testing.T) { + cb := NewCommandBuilder("test").WithAnnotation("simple-key", "value") + + // The annotation should be accepted if it's a valid format + assert.Equal(t, "value", cb.annotations["simple-key"]) + + // Test with invalid annotation - still gets added but logs an error + cb2 := NewCommandBuilder("test").WithAnnotation("invalid..key", "value") + assert.Equal(t, "value", cb2.annotations["invalid..key"]) // Invalid annotations are still added but logged +} + +func TestCommandBuilderWithTimeout(t *testing.T) { + timeout := 60 * time.Second + cb := NewCommandBuilder("test").WithTimeout(timeout) + + assert.Equal(t, timeout, cb.timeout) +} + +func TestCommandBuilderWithFlags(t *testing.T) { + cb := NewCommandBuilder("test"). + WithDryRun(true). + WithForce(true). + WithWait(true). + WithValidation(false) + + assert.True(t, cb.dryRun) + assert.True(t, cb.force) + assert.True(t, cb.wait) + assert.False(t, cb.validate) +} + +func TestCommandBuilderWithCache(t *testing.T) { + cb := NewCommandBuilder("test").WithCache(true) + + assert.True(t, cb.cached) +} + +func TestCommandBuilderWithCacheTTL(t *testing.T) { + ttl := 10 * time.Minute + cb := NewCommandBuilder("test").WithCacheTTL(ttl) + + assert.Equal(t, ttl, cb.cacheTTL) +} + +func TestCommandBuilderWithCacheKey(t *testing.T) { + cb := NewCommandBuilder("test").WithCacheKey("custom-key") + + assert.Equal(t, "custom-key", cb.cacheKey) +} + +func TestCommandBuilderBuild(t *testing.T) { + cb := NewCommandBuilder("kubectl"). + WithArgs("get", "pods"). + WithNamespace("default"). + WithContext("minikube"). + WithKubeconfig("/path/to/config"). + WithOutput("json"). + WithLabel("app", "web"). + WithDryRun(true). + WithForce(true). + WithWait(true). + WithValidation(false) + + command, args, err := cb.Build() + require.NoError(t, err) + + assert.Equal(t, "kubectl", command) + assert.Contains(t, args, "get") + assert.Contains(t, args, "pods") + assert.Contains(t, args, "--namespace") + assert.Contains(t, args, "default") + assert.Contains(t, args, "--context") + assert.Contains(t, args, "minikube") + assert.Contains(t, args, "--kubeconfig") + assert.Contains(t, args, "/path/to/config") + assert.Contains(t, args, "--output") + assert.Contains(t, args, "json") + assert.Contains(t, args, "--selector") + assert.Contains(t, args, "app=web") + assert.Contains(t, args, "--dry-run=client") + assert.Contains(t, args, "--force") + assert.Contains(t, args, "--wait") + assert.Contains(t, args, "--validate=false") +} + +func TestCommandBuilderBuildWithTimeout(t *testing.T) { + cb := NewCommandBuilder("kubectl"). + WithArgs("delete", "pod", "test-pod"). + WithTimeout(45 * time.Second) + + command, args, err := cb.Build() + require.NoError(t, err) + + assert.Equal(t, "kubectl", command) + assert.Contains(t, args, "--timeout") + assert.Contains(t, args, "45s") +} + +func TestCommandBuilderBuildWithMultipleLabels(t *testing.T) { + cb := NewCommandBuilder("kubectl"). + WithArgs("get", "pods"). + WithLabel("app", "web"). + WithLabel("version", "v1.0.0") + + command, args, err := cb.Build() + require.NoError(t, err) + + assert.Equal(t, "kubectl", command) + assert.Contains(t, args, "--selector") + + // Find the selector argument + var selectorValue string + for i, arg := range args { + if arg == "--selector" && i+1 < len(args) { + selectorValue = args[i+1] + break + } + } + + assert.Contains(t, selectorValue, "app=web") + assert.Contains(t, selectorValue, "version=v1.0.0") +} + +func TestGetPods(t *testing.T) { + namespace := "default" + labels := map[string]string{"app": "web"} + + cb := GetPods(namespace, labels) + + assert.Equal(t, "kubectl", cb.command) + assert.Contains(t, cb.args, "get") + assert.Contains(t, cb.args, "pods") + assert.Equal(t, namespace, cb.namespace) + assert.Equal(t, labels, cb.labels) + assert.True(t, cb.cached) + assert.Empty(t, cb.output) // No default output format +} + +func TestGetServices(t *testing.T) { + namespace := "default" + labels := map[string]string{"app": "web"} + + cb := GetServices(namespace, labels) + + assert.Equal(t, "kubectl", cb.command) + assert.Contains(t, cb.args, "get") + assert.Contains(t, cb.args, "services") + assert.Equal(t, namespace, cb.namespace) + assert.Equal(t, labels, cb.labels) + assert.True(t, cb.cached) + assert.Empty(t, cb.output) // No default output format +} + +func TestGetDeployments(t *testing.T) { + namespace := "default" + labels := map[string]string{"app": "web"} + + cb := GetDeployments(namespace, labels) + + assert.Equal(t, "kubectl", cb.command) + assert.Contains(t, cb.args, "get") + assert.Contains(t, cb.args, "deployments") + assert.Equal(t, namespace, cb.namespace) + assert.Equal(t, labels, cb.labels) + assert.True(t, cb.cached) + assert.Empty(t, cb.output) // No default output format +} + +func TestDescribeResource(t *testing.T) { + resourceType := "pod" + resourceName := "test-pod" + namespace := "default" + + cb := DescribeResource(resourceType, resourceName, namespace) + + assert.Equal(t, "kubectl", cb.command) + assert.Contains(t, cb.args, "describe") + assert.Contains(t, cb.args, resourceType) + assert.Contains(t, cb.args, resourceName) + assert.Equal(t, namespace, cb.namespace) + assert.True(t, cb.cached) + assert.Equal(t, 2*time.Minute, cb.cacheTTL) +} + +func TestGetLogs(t *testing.T) { + podName := "test-pod" + namespace := "default" + options := LogOptions{ + Container: "app", + Follow: true, + Previous: false, + Timestamps: true, + TailLines: 100, + SinceTime: "2023-01-01T00:00:00Z", + SinceDuration: "1h", + } + + cb := GetLogs(podName, namespace, options) + + assert.Equal(t, "kubectl", cb.command) + assert.Contains(t, cb.args, "logs") + assert.Contains(t, cb.args, podName) + assert.Equal(t, namespace, cb.namespace) + assert.Contains(t, cb.args, "--container") + assert.Contains(t, cb.args, "app") + assert.Contains(t, cb.args, "--follow") + assert.Contains(t, cb.args, "--timestamps") + assert.Contains(t, cb.args, "--tail") + assert.Contains(t, cb.args, "100") + assert.Contains(t, cb.args, "--since-time") + assert.Contains(t, cb.args, "2023-01-01T00:00:00Z") + assert.Contains(t, cb.args, "--since") + assert.Contains(t, cb.args, "1h") + assert.False(t, cb.cached) +} + +func TestGetLogsWithPrevious(t *testing.T) { + podName := "test-pod" + namespace := "default" + options := LogOptions{ + Previous: true, + } + + cb := GetLogs(podName, namespace, options) + + assert.Contains(t, cb.args, "--previous") +} + +func TestApplyResource(t *testing.T) { + filename := "/path/to/resource.yaml" + namespace := "default" + options := ApplyOptions{ + DryRun: true, + Force: true, + Wait: true, + Validate: false, + } + + cb := ApplyResource(filename, namespace, options) + + assert.Equal(t, "kubectl", cb.command) + assert.Contains(t, cb.args, "apply") + assert.Contains(t, cb.args, "-f") + assert.Contains(t, cb.args, filename) + assert.Equal(t, namespace, cb.namespace) + assert.True(t, cb.dryRun) + assert.True(t, cb.force) + assert.True(t, cb.wait) + assert.False(t, cb.validate) + assert.False(t, cb.cached) +} + +func TestDeleteResource(t *testing.T) { + resourceType := "pod" + resourceName := "test-pod" + namespace := "default" + options := DeleteOptions{ + Force: true, + GracePeriod: 30, + Wait: true, + } + + cb := DeleteResource(resourceType, resourceName, namespace, options) + + assert.Equal(t, "kubectl", cb.command) + assert.Contains(t, cb.args, "delete") + assert.Contains(t, cb.args, resourceType) + assert.Contains(t, cb.args, resourceName) + assert.Equal(t, namespace, cb.namespace) + assert.True(t, cb.force) + assert.True(t, cb.wait) + assert.False(t, cb.cached) +} + +func TestHelmInstall(t *testing.T) { + releaseName := "test-release" + chart := "bitnami/nginx" + namespace := "default" + options := HelmInstallOptions{ + CreateNamespace: true, + DryRun: true, + Wait: true, + ValuesFile: "/path/to/values.yaml", + SetValues: map[string]string{"image.tag": "1.20"}, + } + + cb := HelmInstall(releaseName, chart, namespace, options) + + assert.Equal(t, "helm", cb.command) + assert.Contains(t, cb.args, "install") + assert.Contains(t, cb.args, releaseName) + assert.Contains(t, cb.args, chart) + assert.Equal(t, namespace, cb.namespace) + assert.True(t, cb.dryRun) + assert.True(t, cb.wait) + assert.False(t, cb.cached) +} + +func TestHelmList(t *testing.T) { + namespace := "default" + options := HelmListOptions{ + AllNamespaces: true, + Output: "json", + } + + cb := HelmList(namespace, options) + + assert.Equal(t, "helm", cb.command) + assert.Contains(t, cb.args, "list") + assert.Equal(t, namespace, cb.namespace) + assert.Equal(t, "json", cb.output) + assert.True(t, cb.cached) +} + +func TestIstioProxyStatus(t *testing.T) { + podName := "test-pod" + namespace := "default" + + cb := IstioProxyStatus(podName, namespace) + + assert.Equal(t, "istioctl", cb.command) + assert.Contains(t, cb.args, "proxy-status") + assert.Contains(t, cb.args, podName) + assert.Equal(t, namespace, cb.namespace) + assert.True(t, cb.cached) +} + +func TestCiliumStatus(t *testing.T) { + cb := CiliumStatus() + + assert.Equal(t, "cilium", cb.command) + assert.Contains(t, cb.args, "status") + assert.Empty(t, cb.output) // CiliumStatus doesn't set output format + assert.True(t, cb.cached) +} + +func TestArgoRolloutsGet(t *testing.T) { + rolloutName := "test-rollout" + namespace := "default" + + cb := ArgoRolloutsGet(rolloutName, namespace) + + assert.Equal(t, "kubectl", cb.command) + assert.Contains(t, cb.args, "argo") + assert.Contains(t, cb.args, "rollouts") + assert.Contains(t, cb.args, "get") + assert.Contains(t, cb.args, "rollout") + assert.Contains(t, cb.args, rolloutName) + assert.Equal(t, namespace, cb.namespace) + assert.Empty(t, cb.output) // ArgoRolloutsGet doesn't set output format + assert.True(t, cb.cached) +} + +func TestCommandBuilderChaining(t *testing.T) { + cb := NewCommandBuilder("kubectl"). + WithArgs("get", "pods"). + WithNamespace("default"). + WithOutput("json"). + WithLabel("app", "web"). + WithTimeout(60 * time.Second). + WithCache(true). + WithCacheTTL(10 * time.Minute) + + assert.Equal(t, "kubectl", cb.command) + assert.Equal(t, []string{"get", "pods"}, cb.args) + assert.Equal(t, "default", cb.namespace) + assert.Equal(t, "json", cb.output) + assert.Equal(t, "web", cb.labels["app"]) + assert.Equal(t, 60*time.Second, cb.timeout) + assert.True(t, cb.cached) + assert.Equal(t, 10*time.Minute, cb.cacheTTL) +} + +func TestCommandBuilderEmptyNamespace(t *testing.T) { + cb := GetPods("", nil) + + assert.Empty(t, cb.namespace) +} + +func TestCommandBuilderEmptyLabels(t *testing.T) { + cb := GetPods("default", nil) + + assert.Empty(t, cb.labels) +} + +func TestLogOptionsDefaults(t *testing.T) { + options := LogOptions{} + + assert.False(t, options.Follow) + assert.False(t, options.Previous) + assert.False(t, options.Timestamps) + assert.Equal(t, 0, options.TailLines) + assert.Empty(t, options.SinceTime) + assert.Empty(t, options.SinceDuration) +} + +func TestApplyOptionsDefaults(t *testing.T) { + options := ApplyOptions{} + + assert.False(t, options.DryRun) + assert.False(t, options.Force) + assert.False(t, options.Wait) + assert.False(t, options.Validate) +} + +func TestDeleteOptionsDefaults(t *testing.T) { + options := DeleteOptions{} + + assert.False(t, options.Force) + assert.Equal(t, 0, options.GracePeriod) + assert.False(t, options.Wait) +} + +func TestHelmInstallOptionsDefaults(t *testing.T) { + options := HelmInstallOptions{} + + assert.False(t, options.CreateNamespace) + assert.False(t, options.DryRun) + assert.False(t, options.Wait) + assert.Empty(t, options.ValuesFile) + assert.Nil(t, options.SetValues) +} + +func TestHelmListOptionsDefaults(t *testing.T) { + options := HelmListOptions{} + + assert.False(t, options.AllNamespaces) + assert.Empty(t, options.Output) +} + +// Mock tests for Execute method - these would need a mock for utils.RunCommandWithContext +func TestCommandBuilderExecuteWithoutCache(t *testing.T) { + cb := NewCommandBuilder("echo"). + WithArgs("hello", "world"). + WithCache(false) + + // This test would need mocking to work properly + // For now, we'll just verify the command building part + command, args, err := cb.Build() + require.NoError(t, err) + + assert.Equal(t, "echo", command) + assert.Contains(t, args, "hello") + assert.Contains(t, args, "world") +} + +func TestCommandBuilderExecuteWithCache(t *testing.T) { + cb := NewCommandBuilder("echo"). + WithArgs("hello", "world"). + WithCache(true) + + // This test would need mocking to work properly + // For now, we'll just verify the command building part + command, args, err := cb.Build() + require.NoError(t, err) + + assert.Equal(t, "echo", command) + assert.Contains(t, args, "hello") + assert.Contains(t, args, "world") + assert.True(t, cb.cached) +} diff --git a/internal/errors/tool_errors.go b/internal/errors/tool_errors.go new file mode 100644 index 00000000..12a7fd9c --- /dev/null +++ b/internal/errors/tool_errors.go @@ -0,0 +1,423 @@ +package errors + +import ( + "fmt" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// ToolError represents a structured error with context and recovery suggestions +type ToolError struct { + Operation string `json:"operation"` + Cause error `json:"cause"` + Suggestions []string `json:"suggestions"` + IsRetryable bool `json:"is_retryable"` + Timestamp time.Time `json:"timestamp"` + ErrorCode string `json:"error_code"` + Component string `json:"component"` + ResourceType string `json:"resource_type,omitempty"` + ResourceName string `json:"resource_name,omitempty"` + Context map[string]interface{} `json:"context,omitempty"` +} + +// Error implements the error interface +func (e *ToolError) Error() string { + return fmt.Sprintf("[%s] %s failed: %v", e.Component, e.Operation, e.Cause) +} + +// ToMCPResult converts the error to an MCP result with rich context +func (e *ToolError) ToMCPResult() *mcp.CallToolResult { + var message strings.Builder + + // Format the error message with context + message.WriteString(fmt.Sprintf("❌ **%s Error**\n\n", e.Component)) + message.WriteString(fmt.Sprintf("**Operation**: %s\n", e.Operation)) + message.WriteString(fmt.Sprintf("**Error**: %s\n", e.Cause.Error())) + + if e.ResourceType != "" { + message.WriteString(fmt.Sprintf("**Resource Type**: %s\n", e.ResourceType)) + } + + if e.ResourceName != "" { + message.WriteString(fmt.Sprintf("**Resource Name**: %s\n", e.ResourceName)) + } + + message.WriteString(fmt.Sprintf("**Error Code**: %s\n", e.ErrorCode)) + message.WriteString(fmt.Sprintf("**Timestamp**: %s\n", e.Timestamp.Format(time.RFC3339))) + + if e.IsRetryable { + message.WriteString("**Retryable**: Yes\n") + } else { + message.WriteString("**Retryable**: No\n") + } + + if len(e.Suggestions) > 0 { + message.WriteString("\n**💡 Suggestions**:\n") + for i, suggestion := range e.Suggestions { + message.WriteString(fmt.Sprintf("%d. %s\n", i+1, suggestion)) + } + } + + if len(e.Context) > 0 { + message.WriteString("\n**📋 Context**:\n") + for key, value := range e.Context { + message.WriteString(fmt.Sprintf("- %s: %v\n", key, value)) + } + } + + return mcp.NewToolResultError(message.String()) +} + +// NewToolError creates a new structured tool error +func NewToolError(component, operation string, cause error) *ToolError { + return &ToolError{ + Operation: operation, + Cause: cause, + Suggestions: []string{}, + IsRetryable: false, + Timestamp: time.Now(), + ErrorCode: "UNKNOWN", + Component: component, + Context: make(map[string]interface{}), + } +} + +// WithSuggestions adds recovery suggestions to the error +func (e *ToolError) WithSuggestions(suggestions ...string) *ToolError { + e.Suggestions = append(e.Suggestions, suggestions...) + return e +} + +// WithRetryable sets whether the error is retryable +func (e *ToolError) WithRetryable(retryable bool) *ToolError { + e.IsRetryable = retryable + return e +} + +// WithErrorCode sets the error code +func (e *ToolError) WithErrorCode(code string) *ToolError { + e.ErrorCode = code + return e +} + +// WithResource adds resource information to the error +func (e *ToolError) WithResource(resourceType, resourceName string) *ToolError { + e.ResourceType = resourceType + e.ResourceName = resourceName + return e +} + +// WithContext adds contextual information to the error +func (e *ToolError) WithContext(key string, value interface{}) *ToolError { + e.Context[key] = value + return e +} + +// Common error creators for different components + +// NewKubernetesError creates a Kubernetes-specific error +func NewKubernetesError(operation string, cause error) *ToolError { + err := NewToolError("Kubernetes", operation, cause) + + // Add Kubernetes-specific suggestions based on common errors + if strings.Contains(cause.Error(), "connection refused") { + err = err.WithSuggestions( + "Check if the Kubernetes cluster is running", + "Verify your kubeconfig is correct", + "Ensure network connectivity to the cluster", + ).WithRetryable(true).WithErrorCode("K8S_CONNECTION_ERROR") + } else if strings.Contains(cause.Error(), "forbidden") { + err = err.WithSuggestions( + "Check your RBAC permissions", + "Verify your service account has the required permissions", + "Contact your cluster administrator", + ).WithRetryable(false).WithErrorCode("K8S_PERMISSION_ERROR") + } else if strings.Contains(cause.Error(), "not found") { + err = err.WithSuggestions( + "Check if the resource exists", + "Verify the resource name and namespace", + "List available resources to confirm", + ).WithRetryable(false).WithErrorCode("K8S_RESOURCE_NOT_FOUND") + } else if strings.Contains(cause.Error(), "already exists") { + err = err.WithSuggestions( + "Use a different name for the resource", + "Delete the existing resource first", + "Use 'kubectl apply' instead of 'kubectl create'", + ).WithRetryable(false).WithErrorCode("K8S_RESOURCE_EXISTS") + } else { + err = err.WithSuggestions( + "Check the kubectl command syntax", + "Verify your kubeconfig is valid", + "Check cluster connectivity", + ).WithRetryable(true).WithErrorCode("K8S_GENERIC_ERROR") + } + + return err +} + +// NewHelmError creates a Helm-specific error +func NewHelmError(operation string, cause error) *ToolError { + err := NewToolError("Helm", operation, cause) + + if strings.Contains(cause.Error(), "not found") { + err = err.WithSuggestions( + "Check if the Helm release exists", + "Verify the release name and namespace", + "Use 'helm list' to see available releases", + ).WithRetryable(false).WithErrorCode("HELM_RELEASE_NOT_FOUND") + } else if strings.Contains(cause.Error(), "already exists") { + err = err.WithSuggestions( + "Use a different release name", + "Upgrade the existing release instead", + "Uninstall the existing release first", + ).WithRetryable(false).WithErrorCode("HELM_RELEASE_EXISTS") + } else if strings.Contains(cause.Error(), "repository") { + err = err.WithSuggestions( + "Add the required Helm repository", + "Update your Helm repositories", + "Check repository URL and credentials", + ).WithRetryable(true).WithErrorCode("HELM_REPOSITORY_ERROR") + } else { + err = err.WithSuggestions( + "Check the Helm command syntax", + "Verify your kubeconfig is valid", + "Ensure Helm is properly installed", + ).WithRetryable(true).WithErrorCode("HELM_GENERIC_ERROR") + } + + return err +} + +// NewIstioError creates an Istio-specific error +func NewIstioError(operation string, cause error) *ToolError { + err := NewToolError("Istio", operation, cause) + + if strings.Contains(cause.Error(), "not found") { + err = err.WithSuggestions( + "Check if Istio is installed in the cluster", + "Verify the pod/service name and namespace", + "Ensure Istio sidecar is injected", + ).WithRetryable(false).WithErrorCode("ISTIO_RESOURCE_NOT_FOUND") + } else if strings.Contains(cause.Error(), "connection refused") { + err = err.WithSuggestions( + "Check if Istio control plane is running", + "Verify Istio proxy is healthy", + "Check network policies", + ).WithRetryable(true).WithErrorCode("ISTIO_CONNECTION_ERROR") + } else { + err = err.WithSuggestions( + "Check istioctl command syntax", + "Verify Istio installation", + "Check Istio proxy status", + ).WithRetryable(true).WithErrorCode("ISTIO_GENERIC_ERROR") + } + + return err +} + +// NewPrometheusError creates a Prometheus-specific error +func NewPrometheusError(operation string, cause error) *ToolError { + err := NewToolError("Prometheus", operation, cause) + + if strings.Contains(cause.Error(), "connection refused") { + err = err.WithSuggestions( + "Check if Prometheus server is running", + "Verify the Prometheus URL", + "Check network connectivity", + ).WithRetryable(true).WithErrorCode("PROMETHEUS_CONNECTION_ERROR") + } else if strings.Contains(cause.Error(), "parse error") { + err = err.WithSuggestions( + "Check your PromQL query syntax", + "Verify metric names and labels", + "Test the query in Prometheus UI", + ).WithRetryable(false).WithErrorCode("PROMETHEUS_QUERY_ERROR") + } else { + err = err.WithSuggestions( + "Check Prometheus server status", + "Verify the query format", + "Check authentication if required", + ).WithRetryable(true).WithErrorCode("PROMETHEUS_GENERIC_ERROR") + } + + return err +} + +// NewArgoError creates an Argo-specific error +func NewArgoError(operation string, cause error) *ToolError { + err := NewToolError("Argo Rollouts", operation, cause) + + if strings.Contains(cause.Error(), "not found") { + err = err.WithSuggestions( + "Check if Argo Rollouts is installed", + "Verify the rollout name and namespace", + "Use 'kubectl get rollouts' to list available rollouts", + ).WithRetryable(false).WithErrorCode("ARGO_ROLLOUT_NOT_FOUND") + } else if strings.Contains(cause.Error(), "plugin") { + err = err.WithSuggestions( + "Install the kubectl argo rollouts plugin", + "Check plugin version compatibility", + "Verify plugin installation path", + ).WithRetryable(true).WithErrorCode("ARGO_PLUGIN_ERROR") + } else { + err = err.WithSuggestions( + "Check Argo Rollouts installation", + "Verify the command syntax", + "Check RBAC permissions", + ).WithRetryable(true).WithErrorCode("ARGO_GENERIC_ERROR") + } + + return err +} + +// NewCiliumError creates a Cilium-specific error +func NewCiliumError(operation string, cause error) *ToolError { + err := NewToolError("Cilium", operation, cause) + + if strings.Contains(cause.Error(), "not found") { + err = err.WithSuggestions( + "Check if Cilium is installed", + "Verify the cilium CLI is installed", + "Check Cilium agent status", + ).WithRetryable(false).WithErrorCode("CILIUM_NOT_FOUND") + } else if strings.Contains(cause.Error(), "connection") { + err = err.WithSuggestions( + "Check Cilium agent connectivity", + "Verify cluster mesh configuration", + "Check Cilium operator status", + ).WithRetryable(true).WithErrorCode("CILIUM_CONNECTION_ERROR") + } else { + err = err.WithSuggestions( + "Check Cilium installation", + "Verify cilium CLI version", + "Check Cilium system pods", + ).WithRetryable(true).WithErrorCode("CILIUM_GENERIC_ERROR") + } + + return err +} + +// NewKubescapeError creates a Kubescape-specific error +func NewKubescapeError(operation string, cause error) *ToolError { + err := NewToolError("Kubescape", operation, cause) + + causeStr := cause.Error() + if strings.Contains(causeStr, "not found") { + // Determine which capability might be missing based on the operation + suggestions := []string{ + "Check if the Kubescape operator is installed in the cluster", + "Verify the resource name and namespace", + } + if strings.Contains(operation, "vulnerabilit") || strings.Contains(operation, "sbom") { + suggestions = append(suggestions, + "Ensure vulnerability scanning is enabled in the Kubescape Helm chart", + "Enable with: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.vulnerabilityScan=enable", + "Use 'kubectl get vulnerabilitymanifests -A' to list available manifests", + ) + } else if strings.Contains(operation, "configuration") { + suggestions = append(suggestions, + "Ensure configuration scanning is enabled in the Kubescape Helm chart", + "Enable with: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.continuousScan=enable", + "Use 'kubectl get workloadconfigurationscans -A' to list available scans", + ) + } else if strings.Contains(operation, "application_profile") || strings.Contains(operation, "network_neighborhood") { + suggestions = append(suggestions, + "Ensure runtime observability is enabled in the Kubescape Helm chart", + "Enable with: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.runtimeObservability=enable", + "Runtime data collection requires time - allow workloads to run before profiles are available", + ) + if strings.Contains(operation, "application_profile") { + suggestions = append(suggestions, "Use 'kubectl get applicationprofiles -A' to list available profiles") + } else { + suggestions = append(suggestions, "Use 'kubectl get networkneighborhoods -A' to list available network data") + } + } else { + suggestions = append(suggestions, + "Ensure the required scanning capabilities are enabled in the Kubescape Helm chart", + "For vulnerability scanning: --set capabilities.vulnerabilityScan=enable", + "For configuration scanning: --set capabilities.continuousScan=enable", + "For runtime observability: --set capabilities.runtimeObservability=enable", + ) + } + err = err.WithSuggestions(suggestions...).WithRetryable(false).WithErrorCode("KUBESCAPE_RESOURCE_NOT_FOUND") + } else if strings.Contains(causeStr, "connection refused") || strings.Contains(causeStr, "timeout") { + err = err.WithSuggestions( + "Check if the Kubernetes cluster is accessible", + "Verify your kubeconfig is correct", + "Ensure network connectivity to the cluster", + ).WithRetryable(true).WithErrorCode("KUBESCAPE_CONNECTION_ERROR") + } else if strings.Contains(causeStr, "forbidden") { + err = err.WithSuggestions( + "Check your RBAC permissions for Kubescape CRDs", + "Verify your service account has read access to Kubescape storage CRDs", + "Required CRDs: VulnerabilityManifests, WorkloadConfigurationScans, ApplicationProfiles, NetworkNeighborhoods, SBOMSyfts", + "Contact your cluster administrator", + ).WithRetryable(false).WithErrorCode("KUBESCAPE_PERMISSION_ERROR") + } else { + err = err.WithSuggestions( + "Check Kubescape operator status: kubectl get pods -n kubescape", + "Verify kubeconfig is valid", + "Check if CRDs are installed: kubectl get crd vulnerabilitymanifests.spdx.softwarecomposition.kubescape.io", + "Ensure scanning capabilities are enabled in the Helm chart:", + " - Vulnerability scanning: --set capabilities.vulnerabilityScan=enable", + " - Configuration scanning: --set capabilities.continuousScan=enable", + " - Runtime observability: --set capabilities.runtimeObservability=enable", + ).WithRetryable(true).WithErrorCode("KUBESCAPE_GENERIC_ERROR") + } + + return err +} + +// NewValidationError creates a validation error +func NewValidationError(field, message string) *ToolError { + err := NewToolError("Validation", fmt.Sprintf("validate %s", field), fmt.Errorf("%s", message)) + + err = err.WithSuggestions( + "Check the input format", + "Refer to the documentation for valid values", + "Verify the parameter requirements", + ).WithRetryable(false).WithErrorCode("VALIDATION_ERROR") + + return err +} + +// NewSecurityError creates a security-related error +func NewSecurityError(operation string, cause error) *ToolError { + err := NewToolError("Security", operation, cause) + + err = err.WithSuggestions( + "Review the input for potentially dangerous content", + "Use only trusted input sources", + "Contact security team if needed", + ).WithRetryable(false).WithErrorCode("SECURITY_ERROR") + + return err +} + +// NewTimeoutError creates a timeout error +func NewTimeoutError(operation string, timeout time.Duration) *ToolError { + cause := fmt.Errorf("operation timed out after %v", timeout) + err := NewToolError("Timeout", operation, cause) + + err = err.WithSuggestions( + "Try the operation again", + "Check network connectivity", + "Increase timeout if possible", + ).WithRetryable(true).WithErrorCode("TIMEOUT_ERROR") + + return err +} + +// NewCommandError creates a command execution error +func NewCommandError(command string, cause error) *ToolError { + err := NewToolError("Command", fmt.Sprintf("execute %s", command), cause) + + err = err.WithSuggestions( + "Check if the command exists in PATH", + "Verify command syntax and arguments", + "Check system permissions", + ).WithRetryable(true).WithErrorCode("COMMAND_ERROR") + + return err +} diff --git a/internal/errors/tool_errors_test.go b/internal/errors/tool_errors_test.go new file mode 100644 index 00000000..bfa2f24c --- /dev/null +++ b/internal/errors/tool_errors_test.go @@ -0,0 +1,366 @@ +package errors + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNewToolError(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause) + + assert.Equal(t, "test operation", err.Operation) + assert.Equal(t, cause, err.Cause) + assert.Equal(t, "TestComponent", err.Component) + assert.Equal(t, "UNKNOWN", err.ErrorCode) + assert.False(t, err.IsRetryable) + assert.Empty(t, err.Suggestions) + assert.NotNil(t, err.Context) + assert.WithinDuration(t, time.Now(), err.Timestamp, time.Second) +} + +func TestToolErrorError(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause) + + result := err.Error() + expected := "[TestComponent] test operation failed: test error" + assert.Equal(t, expected, result) +} + +func TestToolErrorWithSuggestions(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause) + + err = err.WithSuggestions("suggestion 1", "suggestion 2") + + assert.Equal(t, []string{"suggestion 1", "suggestion 2"}, err.Suggestions) + + // Test chaining + err = err.WithSuggestions("suggestion 3") + assert.Equal(t, []string{"suggestion 1", "suggestion 2", "suggestion 3"}, err.Suggestions) +} + +func TestToolErrorWithRetryable(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause) + + err = err.WithRetryable(true) + assert.True(t, err.IsRetryable) + + err = err.WithRetryable(false) + assert.False(t, err.IsRetryable) +} + +func TestToolErrorWithErrorCode(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause) + + err = err.WithErrorCode("TEST_ERROR") + assert.Equal(t, "TEST_ERROR", err.ErrorCode) +} + +func TestToolErrorWithResource(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause) + + err = err.WithResource("Pod", "test-pod") + assert.Equal(t, "Pod", err.ResourceType) + assert.Equal(t, "test-pod", err.ResourceName) +} + +func TestToolErrorWithContext(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause) + + err = err.WithContext("key1", "value1") + err = err.WithContext("key2", 42) + + assert.Equal(t, "value1", err.Context["key1"]) + assert.Equal(t, 42, err.Context["key2"]) +} + +func TestToolErrorToMCPResult(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause). + WithErrorCode("TEST_ERROR"). + WithResource("Pod", "test-pod"). + WithSuggestions("suggestion 1", "suggestion 2"). + WithContext("key1", "value1"). + WithRetryable(true) + + result := err.ToMCPResult() + + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.NotEmpty(t, result.Content) + + // Check content (assuming it's text content) + if len(result.Content) > 0 { + content := result.Content[0] + // This depends on the actual MCP implementation + // We'll just check that it's not empty + assert.NotNil(t, content) + } +} + +func TestNewKubernetesError(t *testing.T) { + tests := []struct { + name string + causeError string + expectedCode string + expectedRetry bool + expectedSuggs int + }{ + { + name: "connection refused", + causeError: "connection refused", + expectedCode: "K8S_CONNECTION_ERROR", + expectedRetry: true, + expectedSuggs: 3, + }, + { + name: "forbidden", + causeError: "forbidden", + expectedCode: "K8S_PERMISSION_ERROR", + expectedRetry: false, + expectedSuggs: 3, + }, + { + name: "not found", + causeError: "not found", + expectedCode: "K8S_RESOURCE_NOT_FOUND", + expectedRetry: false, + expectedSuggs: 3, + }, + { + name: "already exists", + causeError: "already exists", + expectedCode: "K8S_RESOURCE_EXISTS", + expectedRetry: false, + expectedSuggs: 3, + }, + { + name: "generic error", + causeError: "some other error", + expectedCode: "K8S_GENERIC_ERROR", + expectedRetry: true, + expectedSuggs: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cause := errors.New(tt.causeError) + err := NewKubernetesError("test operation", cause) + + assert.Equal(t, "Kubernetes", err.Component) + assert.Equal(t, tt.expectedCode, err.ErrorCode) + assert.Equal(t, tt.expectedRetry, err.IsRetryable) + assert.Len(t, err.Suggestions, tt.expectedSuggs) + }) + } +} + +func TestNewHelmError(t *testing.T) { + tests := []struct { + name string + causeError string + expectedCode string + expectedRetry bool + expectedSuggs int + }{ + { + name: "not found", + causeError: "not found", + expectedCode: "HELM_RELEASE_NOT_FOUND", + expectedRetry: false, + expectedSuggs: 3, + }, + { + name: "already exists", + causeError: "already exists", + expectedCode: "HELM_RELEASE_EXISTS", + expectedRetry: false, + expectedSuggs: 3, + }, + { + name: "repository error", + causeError: "repository error", + expectedCode: "HELM_REPOSITORY_ERROR", + expectedRetry: true, + expectedSuggs: 3, + }, + { + name: "generic error", + causeError: "some other error", + expectedCode: "HELM_GENERIC_ERROR", + expectedRetry: true, + expectedSuggs: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cause := errors.New(tt.causeError) + err := NewHelmError("test operation", cause) + + assert.Equal(t, "Helm", err.Component) + assert.Equal(t, tt.expectedCode, err.ErrorCode) + assert.Equal(t, tt.expectedRetry, err.IsRetryable) + assert.Len(t, err.Suggestions, tt.expectedSuggs) + }) + } +} + +func TestNewIstioError(t *testing.T) { + cause := errors.New("test error") + err := NewIstioError("test operation", cause) + + assert.Equal(t, "Istio", err.Component) + assert.Equal(t, "test operation", err.Operation) + assert.Equal(t, cause, err.Cause) +} + +func TestNewPrometheusError(t *testing.T) { + cause := errors.New("test error") + err := NewPrometheusError("test operation", cause) + + assert.Equal(t, "Prometheus", err.Component) + assert.Equal(t, "test operation", err.Operation) + assert.Equal(t, cause, err.Cause) +} + +func TestNewArgoError(t *testing.T) { + cause := errors.New("test error") + err := NewArgoError("test operation", cause) + + assert.Equal(t, "Argo Rollouts", err.Component) + assert.Equal(t, "test operation", err.Operation) + assert.Equal(t, cause, err.Cause) +} + +func TestNewCiliumError(t *testing.T) { + cause := errors.New("test error") + err := NewCiliumError("test operation", cause) + + assert.Equal(t, "Cilium", err.Component) + assert.Equal(t, "test operation", err.Operation) + assert.Equal(t, cause, err.Cause) +} + +func TestNewValidationError(t *testing.T) { + err := NewValidationError("test-field", "validation failed") + + assert.Equal(t, "Validation", err.Component) + assert.Equal(t, "validate test-field", err.Operation) + assert.Equal(t, "VALIDATION_ERROR", err.ErrorCode) + assert.False(t, err.IsRetryable) + assert.Contains(t, err.Cause.Error(), "validation failed") +} + +func TestNewSecurityError(t *testing.T) { + cause := errors.New("security violation") + err := NewSecurityError("test operation", cause) + + assert.Equal(t, "Security", err.Component) + assert.Equal(t, "test operation", err.Operation) + assert.Equal(t, cause, err.Cause) + assert.Equal(t, "SECURITY_ERROR", err.ErrorCode) + assert.False(t, err.IsRetryable) +} + +func TestNewTimeoutError(t *testing.T) { + timeout := 30 * time.Second + err := NewTimeoutError("test operation", timeout) + + assert.Equal(t, "Timeout", err.Component) + assert.Equal(t, "test operation", err.Operation) + assert.Equal(t, "TIMEOUT_ERROR", err.ErrorCode) + assert.True(t, err.IsRetryable) + assert.Contains(t, err.Cause.Error(), "30s") +} + +func TestNewCommandError(t *testing.T) { + cause := errors.New("command failed") + err := NewCommandError("test-command", cause) + + assert.Equal(t, "Command", err.Component) + assert.Equal(t, "execute test-command", err.Operation) + assert.Equal(t, cause, err.Cause) + assert.Equal(t, "COMMAND_ERROR", err.ErrorCode) + assert.True(t, err.IsRetryable) +} + +func TestToolErrorChaining(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause). + WithErrorCode("TEST_ERROR"). + WithResource("Pod", "test-pod"). + WithSuggestions("suggestion 1"). + WithContext("key1", "value1"). + WithRetryable(true) + + // Test that all methods return the same instance for chaining + assert.Equal(t, "TEST_ERROR", err.ErrorCode) + assert.Equal(t, "Pod", err.ResourceType) + assert.Equal(t, "test-pod", err.ResourceName) + assert.Equal(t, []string{"suggestion 1"}, err.Suggestions) + assert.Equal(t, "value1", err.Context["key1"]) + assert.True(t, err.IsRetryable) +} + +func TestToolErrorStringRepresentation(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause) + + errorStr := err.Error() + assert.Contains(t, errorStr, "TestComponent") + assert.Contains(t, errorStr, "test operation") + assert.Contains(t, errorStr, "test error") + assert.Contains(t, errorStr, "failed") +} + +func TestToolErrorTimestamp(t *testing.T) { + before := time.Now() + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause) + after := time.Now() + + assert.True(t, err.Timestamp.After(before) || err.Timestamp.Equal(before)) + assert.True(t, err.Timestamp.Before(after) || err.Timestamp.Equal(after)) +} + +func TestToolErrorContextInitialization(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause) + + // Context should be initialized but empty + assert.NotNil(t, err.Context) + assert.Empty(t, err.Context) + + // Should be able to add to context + err = err.WithContext("test", "value") + assert.Equal(t, "value", err.Context["test"]) +} + +func TestMCPResultContainsExpectedFields(t *testing.T) { + cause := errors.New("test error") + err := NewToolError("TestComponent", "test operation", cause). + WithErrorCode("TEST_ERROR"). + WithResource("Pod", "test-pod"). + WithSuggestions("suggestion 1"). + WithContext("key1", "value1"). + WithRetryable(true) + + result := err.ToMCPResult() + + // The result should be an error result + assert.True(t, result.IsError) + + // Should have content + assert.NotEmpty(t, result.Content) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 00000000..0569689d --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,110 @@ +package logger + +import ( + "context" + "log/slog" + "os" + + "go.opentelemetry.io/otel/trace" +) + +var globalLogger *slog.Logger + +// Init initializes the global logger +// If useStderr is true, logs will be written to stderr (for stdio mode) +// If useStderr is false, logs will be written to stdout (for HTTP mode) +func Init(useStderr bool) { + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + } + + // Choose output destination based on mode + output := os.Stdout + if useStderr { + output = os.Stderr + } + + if os.Getenv("KAGENT_LOG_FORMAT") == "json" { + globalLogger = slog.New(slog.NewJSONHandler(output, opts)) + } else { + globalLogger = slog.New(slog.NewTextHandler(output, opts)) + } + + slog.SetDefault(globalLogger) +} + +// InitWithEnv initializes the logger using environment variables +// This is a convenience function that defaults to stdout unless KAGENT_USE_STDERR is set +func InitWithEnv() { + useStderr := os.Getenv("KAGENT_USE_STDERR") == "true" + Init(useStderr) +} + +func Get() *slog.Logger { + if globalLogger == nil { + InitWithEnv() + } + return globalLogger +} + +func WithContext(ctx context.Context) *slog.Logger { + logger := Get() + span := trace.SpanFromContext(ctx) + if span.SpanContext().IsValid() { + logger = logger.With( + "trace_id", span.SpanContext().TraceID().String(), + "span_id", span.SpanContext().SpanID().String(), + ) + } + return logger +} + +// RedactArgsForLog returns a copy of args with sensitive values redacted for logging. +// Any value immediately following "--token" is replaced with "" so tokens are not logged. +func RedactArgsForLog(args []string) []string { + if len(args) == 0 { + return nil + } + out := make([]string, len(args)) + copy(out, args) + for i := 0; i < len(out)-1; i++ { + if out[i] == "--token" { + out[i+1] = "" + i++ // skip the redacted value + } + } + return out +} + +func LogExecCommand(ctx context.Context, logger *slog.Logger, command string, args []string, caller string) { + logger.Info("executing command", + "command", command, + "args", RedactArgsForLog(args), + "caller", caller, + ) +} + +func LogExecCommandResult(ctx context.Context, logger *slog.Logger, command string, args []string, output string, err error, duration float64, caller string) { + redacted := RedactArgsForLog(args) + if err != nil { + logger.Error("command execution failed", + "command", command, + "args", redacted, + "error", err.Error(), + "duration_seconds", duration, + "caller", caller, + ) + } else { + logger.Info("command execution successful", + "command", command, + "args", redacted, + "output", output, + "duration_seconds", duration, + "caller", caller, + ) + } +} + +func Sync() { + // No-op for slog, but kept for compatibility +} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 00000000..f6befc5c --- /dev/null +++ b/internal/logger/logger_test.go @@ -0,0 +1,110 @@ +package logger + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace/noop" +) + +func TestRedactArgsForLog(t *testing.T) { + t.Run("redacts token value", func(t *testing.T) { + args := []string{"get", "pods", "--token", "secret-token-123", "-n", "default"} + redacted := RedactArgsForLog(args) + require.Len(t, redacted, 6) + assert.Equal(t, "get", redacted[0]) + assert.Equal(t, "pods", redacted[1]) + assert.Equal(t, "--token", redacted[2]) + assert.Equal(t, "", redacted[3]) + assert.Equal(t, "-n", redacted[4]) + assert.Equal(t, "default", redacted[5]) + }) + t.Run("empty args returns nil", func(t *testing.T) { + assert.Nil(t, RedactArgsForLog(nil)) + assert.Nil(t, RedactArgsForLog([]string{})) + }) + t.Run("args without token unchanged", func(t *testing.T) { + args := []string{"get", "pods", "-n", "default"} + redacted := RedactArgsForLog(args) + assert.Equal(t, args, redacted) + }) + t.Run("--token at end with no value", func(t *testing.T) { + args := []string{"get", "pods", "--token"} + redacted := RedactArgsForLog(args) + assert.Equal(t, args, redacted) + }) + t.Run("logged output does not contain token", func(t *testing.T) { + var buf bytes.Buffer + log := slog.New(slog.NewTextHandler(&buf, nil)) + args := []string{"get", "pods", "--token", "secret-token-123"} + log.Info("executing command", "command", "kubectl", "args", RedactArgsForLog(args)) + output := buf.String() + assert.Contains(t, output, "") + assert.NotContains(t, output, "secret-token-123") + }) +} + +func TestLogExecCommand(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + ctx := context.Background() + LogExecCommand(ctx, logger, "test-command", []string{"arg1", "arg2"}, "test.go:123") + + output := buf.String() + assert.Contains(t, output, "executing command") + assert.Contains(t, output, "test-command") + assert.Contains(t, output, "arg1") + assert.Contains(t, output, "arg2") +} + +func TestLogExecCommandResult(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + ctx := context.Background() + LogExecCommandResult(ctx, logger, "test-command", []string{"arg1"}, "success output", nil, 1.5, "test.go:123") + assert.Contains(t, buf.String(), "command execution successful") + + buf.Reset() + LogExecCommandResult(ctx, logger, "test-command", []string{"arg1"}, "error output", assert.AnError, 0.5, "test.go:123") + assert.Contains(t, buf.String(), "command execution failed") +} + +func TestWithContextAddsTraceID(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + + // Create a context with a mock span + tp := noop.NewTracerProvider() + ctx, span := tp.Tracer("test").Start(context.Background(), "test-span") + defer span.End() + + loggerWithTrace := logger.With("trace_id", span.SpanContext().TraceID().String()) + loggerWithTrace.InfoContext(ctx, "test message") + + var logOutput map[string]interface{} + err := json.Unmarshal(buf.Bytes(), &logOutput) + require.NoError(t, err) + + traceID := span.SpanContext().TraceID().String() + assert.Equal(t, traceID, logOutput["trace_id"]) +} + +func TestGet(t *testing.T) { + assert.NotNil(t, Get()) +} + +func TestInit(t *testing.T) { + assert.NotPanics(t, func() { Init(false) }) + assert.NotPanics(t, func() { Init(true) }) +} + +func TestSync(t *testing.T) { + assert.NotPanics(t, Sync) +} diff --git a/internal/metrics/monitoring_server.go b/internal/metrics/monitoring_server.go new file mode 100644 index 00000000..275a01fd --- /dev/null +++ b/internal/metrics/monitoring_server.go @@ -0,0 +1,69 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" +) + +// kAgent Tools MCP Server metrics definition +var ( + KagentToolsMCPServerInfo = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "kagent_tools_mcp_server_info", + Help: "Information about the MCP server including version and build details", + }, + []string{ + "server_name", + "version", + "git_commit", + "build_date", + "server_mode", // e.g., "read-only" or "read-write" + }, + ) + + KagentToolsMCPRegisteredTools = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "kagent_tools_mcp_registered_tools", + Help: "Set to 1 for each registered MCP tool provider", + }, + []string{ + "tool_name", + "tool_provider", + }, + ) + + KagentToolsMCPInvocationsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "kagent_tools_mcp_invocations_total", + Help: "Total number of MCP tool invocations", + }, + []string{"tool_name", "tool_provider"}, + ) + + KagentToolsMCPInvocationsFailureTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "kagent_tools_mcp_invocations_failure_total", + Help: "Total number of failed MCP tool invocations", + }, + []string{"tool_name", "tool_provider"}, + ) +) + +func InitServer() *prometheus.Registry { + // New registry for our custom metrics, separate from the default registry + registry := prometheus.NewRegistry() + + // Add Go runtime metrics ( goroutines, GC stats, etc. ) + registry.MustRegister(collectors.NewGoCollector()) + + // Add process metrics (CPU, memory, file descriptors, etc. ) + registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + + // Register kAgent Tools MCP Server metrics + registry.MustRegister(KagentToolsMCPServerInfo) + registry.MustRegister(KagentToolsMCPRegisteredTools) + registry.MustRegister(KagentToolsMCPInvocationsTotal) + registry.MustRegister(KagentToolsMCPInvocationsFailureTotal) + + return registry +} diff --git a/internal/metrics/monitoring_server_test.go b/internal/metrics/monitoring_server_test.go new file mode 100644 index 00000000..495c3e19 --- /dev/null +++ b/internal/metrics/monitoring_server_test.go @@ -0,0 +1,268 @@ +package metrics + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" +) + +func TestInitServer_ReturnsRegistry(t *testing.T) { + registry := InitServer() + if registry == nil { + t.Fatal("InitServer() returned nil registry") + } +} + +func TestInitServer_GathersMetrics(t *testing.T) { + registry := InitServer() + + families, err := registry.Gather() + if err != nil { + t.Fatalf("Failed to gather metrics: %v", err) + } + + if len(families) == 0 { + t.Fatal("Expected at least one metric family from Go/process collectors, got none") + } +} + +func TestInitServer_RegistersCustomMetrics(t *testing.T) { + registry := InitServer() + + families, err := registry.Gather() + if err != nil { + t.Fatalf("Failed to gather metrics: %v", err) + } + + // Build a set of metric names for easy lookup + metricNames := make(map[string]bool) + for _, family := range families { + metricNames[family.GetName()] = true + } + + // Go and process collectors should be present + goMetrics := []string{ + "go_goroutines", + "go_memstats_alloc_bytes", + } + for _, name := range goMetrics { + if !metricNames[name] { + t.Errorf("Expected Go collector metric %q to be registered", name) + } + } +} + +func TestKagentToolsMCPServerInfo_SetAndGather(t *testing.T) { + registry := InitServer() + + // Set the server info metric + KagentToolsMCPServerInfo.WithLabelValues( + "test-server", + "v0.0.1", + "abc123", + "2026-02-12", + "read-write", + ).Set(1) + + families, err := registry.Gather() + if err != nil { + t.Fatalf("Failed to gather metrics: %v", err) + } + + found := findMetricFamily(families, "kagent_tools_mcp_server_info") + if found == nil { + t.Fatal("Expected kagent_tools_mcp_server_info metric to be present") + } + + metrics := found.GetMetric() + if len(metrics) != 1 { + t.Fatalf("Expected 1 time series, got %d", len(metrics)) + } + + // Verify label values + expectedLabels := map[string]string{ + "server_name": "test-server", + "version": "v0.0.1", + "git_commit": "abc123", + "build_date": "2026-02-12", + "server_mode": "read-write", + } + + for _, label := range metrics[0].GetLabel() { + expected, ok := expectedLabels[label.GetName()] + if !ok { + t.Errorf("Unexpected label %q", label.GetName()) + continue + } + if label.GetValue() != expected { + t.Errorf("Label %q: expected %q, got %q", label.GetName(), expected, label.GetValue()) + } + } + + // Verify gauge value is 1 + if metrics[0].GetGauge().GetValue() != 1 { + t.Errorf("Expected gauge value 1, got %f", metrics[0].GetGauge().GetValue()) + } +} + +func TestKagentToolsMCPRegisteredTools_SetAndGather(t *testing.T) { + registry := InitServer() + + // Register a couple of tool providers + KagentToolsMCPRegisteredTools.WithLabelValues("kubectl_get", "k8s").Set(1) + KagentToolsMCPRegisteredTools.WithLabelValues("helm_list", "helm").Set(1) + + families, err := registry.Gather() + if err != nil { + t.Fatalf("Failed to gather metrics: %v", err) + } + + found := findMetricFamily(families, "kagent_tools_mcp_registered_tools") + if found == nil { + t.Fatal("Expected kagent_tools_mcp_registered_tools metric to be present") + } + + metrics := found.GetMetric() + if len(metrics) != 2 { + t.Fatalf("Expected 2 time series (one per tool), got %d", len(metrics)) + } +} + +func TestKagentToolsMCPInvocationsTotal_IncAndGather(t *testing.T) { + registry := InitServer() + + // Simulate a few tool invocations + KagentToolsMCPInvocationsTotal.WithLabelValues("kubectl_get", "k8s").Inc() + KagentToolsMCPInvocationsTotal.WithLabelValues("kubectl_get", "k8s").Inc() + KagentToolsMCPInvocationsTotal.WithLabelValues("helm_list", "helm").Inc() + + families, err := registry.Gather() + if err != nil { + t.Fatalf("Failed to gather metrics: %v", err) + } + + found := findMetricFamily(families, "kagent_tools_mcp_invocations_total") + if found == nil { + t.Fatal("Expected kagent_tools_mcp_invocations_total metric to be present") + } + + metrics := found.GetMetric() + if len(metrics) != 2 { + t.Fatalf("Expected 2 time series (one per tool), got %d", len(metrics)) + } + + // Find the kubectl_get series and verify its counter value is 2 + for _, m := range metrics { + for _, label := range m.GetLabel() { + if label.GetName() == "tool_name" && label.GetValue() == "kubectl_get" { + if m.GetCounter().GetValue() != 2 { + t.Errorf("Expected kubectl_get counter to be 2, got %f", m.GetCounter().GetValue()) + } + } + } + } +} + +func TestKagentToolsMCPInvocationsFailureTotal_IncAndGather(t *testing.T) { + registry := InitServer() + + // Simulate a tool failure + KagentToolsMCPInvocationsFailureTotal.WithLabelValues("helm_install", "helm").Inc() + + families, err := registry.Gather() + if err != nil { + t.Fatalf("Failed to gather metrics: %v", err) + } + + found := findMetricFamily(families, "kagent_tools_mcp_invocations_failure_total") + if found == nil { + t.Fatal("Expected kagent_tools_mcp_invocations_failure_total metric to be present") + } + + metrics := found.GetMetric() + if len(metrics) != 1 { + t.Fatalf("Expected 1 time series, got %d", len(metrics)) + } + + if metrics[0].GetCounter().GetValue() != 1 { + t.Errorf("Expected failure counter to be 1, got %f", metrics[0].GetCounter().GetValue()) + } + + // Verify labels + expectedLabels := map[string]string{ + "tool_name": "helm_install", + "tool_provider": "helm", + } + for _, label := range metrics[0].GetLabel() { + expected, ok := expectedLabels[label.GetName()] + if !ok { + t.Errorf("Unexpected label %q", label.GetName()) + continue + } + if label.GetValue() != expected { + t.Errorf("Label %q: expected %q, got %q", label.GetName(), expected, label.GetValue()) + } + } +} + +// findMetricFamily finds a metric family by name from a gathered slice +func findMetricFamily(families []*dto.MetricFamily, name string) *dto.MetricFamily { + for _, family := range families { + if family.GetName() == name { + return family + } + } + return nil +} + +// resetMetrics resets the global metric vectors so tests don't interfere with each other +func resetMetrics() { + KagentToolsMCPServerInfo = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "kagent_tools_mcp_server_info", + Help: "Information about the MCP server including version and build details", + }, + []string{ + "server_name", + "version", + "git_commit", + "build_date", + "server_mode", + }, + ) + + KagentToolsMCPRegisteredTools = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "kagent_tools_mcp_registered_tools", + Help: "Set to 1 for each registered MCP tool provider", + }, + []string{ + "tool_name", + "tool_provider", + }, + ) + + KagentToolsMCPInvocationsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "kagent_tools_mcp_invocations_total", + Help: "Total number of MCP tool invocations", + }, + []string{"tool_name", "tool_provider"}, + ) + + KagentToolsMCPInvocationsFailureTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "kagent_tools_mcp_invocations_failure_total", + Help: "Total number of failed MCP tool invocations", + }, + []string{"tool_name", "tool_provider"}, + ) +} + +func TestMain(m *testing.M) { + // Reset metrics before each test run to avoid "duplicate registration" panics + // since InitServer() registers the package-level vars into a new registry each time + resetMetrics() + m.Run() +} diff --git a/internal/security/validation.go b/internal/security/validation.go new file mode 100644 index 00000000..f06bf476 --- /dev/null +++ b/internal/security/validation.go @@ -0,0 +1,287 @@ +package security + +import ( + "fmt" + "regexp" + "strings" +) + +// ValidationError represents a validation error +type ValidationError struct { + Field string + Message string +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("validation error in field '%s': %s", e.Field, e.Message) +} + +// Common validation patterns +var ( + // K8s resource name pattern (RFC 1123) + k8sNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) + + // Namespace pattern + namespacePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) + + // Container image pattern + imagePattern = regexp.MustCompile(`^[a-z0-9]+(([._-][a-z0-9]+)*(/[a-z0-9]+(([._-][a-z0-9]+)*)?)*)?(:([a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?))$`) + + // Path pattern (no directory traversal) + pathPattern = regexp.MustCompile(`^[a-zA-Z0-9._/-]+$`) + + // Command injection patterns to reject + commandInjectionPatterns = []*regexp.Regexp{ + regexp.MustCompile(`[;&|` + "`" + `$(){}[\]\\<>*?~!#\n\r\t]`), + regexp.MustCompile(`\.\./`), + regexp.MustCompile(`\$\{`), + regexp.MustCompile(`\$\(`), + regexp.MustCompile(`\|\|`), + regexp.MustCompile(`&&`), + } +) + +// ValidateK8sResourceName validates a Kubernetes resource name +func ValidateK8sResourceName(name string) error { + if name == "" { + return ValidationError{Field: "name", Message: "cannot be empty"} + } + + if len(name) > 63 { + return ValidationError{Field: "name", Message: "cannot exceed 63 characters"} + } + + if !k8sNamePattern.MatchString(name) { + return ValidationError{Field: "name", Message: "must follow RFC 1123 naming convention"} + } + + return nil +} + +// ValidateNamespace validates a Kubernetes namespace +func ValidateNamespace(namespace string) error { + if namespace == "" { + return nil // Empty namespace is allowed (defaults to 'default') + } + + if len(namespace) > 63 { + return ValidationError{Field: "namespace", Message: "cannot exceed 63 characters"} + } + + if !namespacePattern.MatchString(namespace) { + return ValidationError{Field: "namespace", Message: "must follow RFC 1123 naming convention"} + } + + // Reserved namespaces + reserved := []string{"kube-system", "kube-public", "kube-node-lease"} + for _, res := range reserved { + if namespace == res { + return ValidationError{Field: "namespace", Message: fmt.Sprintf("'%s' is a reserved namespace", namespace)} + } + } + + return nil +} + +// ValidateContainerImage validates a container image reference +func ValidateContainerImage(image string) error { + if image == "" { + return ValidationError{Field: "image", Message: "cannot be empty"} + } + + if len(image) > 255 { + return ValidationError{Field: "image", Message: "cannot exceed 255 characters"} + } + + if !imagePattern.MatchString(image) { + return ValidationError{Field: "image", Message: "invalid image format"} + } + + return nil +} + +// ValidateFilePath validates a file path for security +func ValidateFilePath(path string) error { + if len(path) > 4096 { + return ValidationError{Field: "path", Message: "path too long"} + } + + if strings.Contains(path, "..") { + return ValidationError{Field: "path", Message: "path traversal not allowed"} + } + + if !pathPattern.MatchString(path) { + return ValidationError{Field: "path", Message: "contains invalid characters"} + } + + return nil +} + +// ValidateCommandInput validates command inputs for injection attacks +func ValidateCommandInput(input string) error { + if input == "" { + return ValidationError{Field: "input", Message: "cannot be empty"} + } + + if len(input) > 1024 { + return ValidationError{Field: "input", Message: "input too long"} + } + + for _, pattern := range commandInjectionPatterns { + if pattern.MatchString(input) { + return ValidationError{Field: "input", Message: "potentially dangerous characters detected"} + } + } + + return nil +} + +// SanitizeInput sanitizes input strings by replacing potentially dangerous characters +func SanitizeInput(input string) string { + // Replace dangerous characters with safe alternatives + sanitized := strings.ReplaceAll(input, "\n", " ") + sanitized = strings.ReplaceAll(sanitized, "\r", " ") + sanitized = strings.ReplaceAll(sanitized, "\t", " ") + + // Replace multiple spaces with single space + spacePattern := regexp.MustCompile(`\s+`) + sanitized = spacePattern.ReplaceAllString(sanitized, " ") + + sanitized = strings.TrimSpace(sanitized) + + return sanitized +} + +// ValidateK8sLabel validates a Kubernetes label key and value +func ValidateK8sLabel(key, value string) error { + if key == "" { + return ValidationError{Field: "label_key", Message: "cannot be empty"} + } + + if len(key) > 63 { + return ValidationError{Field: "label_key", Message: "cannot exceed 63 characters"} + } + + if len(value) > 63 { + return ValidationError{Field: "label_value", Message: "cannot exceed 63 characters"} + } + + // Label key validation + labelKeyPattern := regexp.MustCompile(`^[a-z0-9A-Z]([a-z0-9A-Z._-]*[a-z0-9A-Z])?$`) + if !labelKeyPattern.MatchString(key) { + return ValidationError{Field: "label_key", Message: "invalid label key format"} + } + + // Label value validation (can be empty) + if value != "" { + labelValuePattern := regexp.MustCompile(`^[a-z0-9A-Z]([a-z0-9A-Z._-]*[a-z0-9A-Z])?$`) + if !labelValuePattern.MatchString(value) { + return ValidationError{Field: "label_value", Message: "invalid label value format"} + } + } + + return nil +} + +// ValidatePromQLQuery validates a PromQL query for basic security +func ValidatePromQLQuery(query string) error { + if query == "" { + return ValidationError{Field: "query", Message: "cannot be empty"} + } + + if len(query) > 8192 { + return ValidationError{Field: "query", Message: "query too long"} + } + + // Basic PromQL validation - no shell commands + dangerousPatterns := []string{ + "`", "$", "$(", "${", "&&", "||", ";", "|", ">", "<", "&", + } + + for _, pattern := range dangerousPatterns { + if strings.Contains(query, pattern) { + return ValidationError{Field: "query", Message: "potentially dangerous characters in query"} + } + } + + return nil +} + +// ValidateYAMLContent validates YAML content for basic security +func ValidateYAMLContent(content string) error { + if content == "" { + return ValidationError{Field: "content", Message: "cannot be empty"} + } + + if len(content) > 1024*1024 { // 1MB limit + return ValidationError{Field: "content", Message: "content too large"} + } + + // Check for potentially dangerous YAML content + dangerousPatterns := []string{ + "!!python/object/apply", + "!!python/object/new", + "!!python/object", + "__import__", + "eval(", + "exec(", + } + + for _, pattern := range dangerousPatterns { + if strings.Contains(content, pattern) { + return ValidationError{Field: "content", Message: "potentially dangerous YAML content detected"} + } + } + + return nil +} + +// ValidateHelmReleaseName validates a Helm release name +func ValidateHelmReleaseName(name string) error { + if name == "" { + return ValidationError{Field: "release_name", Message: "cannot be empty"} + } + + if len(name) > 53 { + return ValidationError{Field: "release_name", Message: "cannot exceed 53 characters"} + } + + // Helm release name pattern + helmNamePattern := regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) + if !helmNamePattern.MatchString(name) { + return ValidationError{Field: "release_name", Message: "must follow DNS naming convention"} + } + + return nil +} + +// ValidateURL validates a URL for basic security +func ValidateURL(url string) error { + if url == "" { + return ValidationError{Field: "url", Message: "cannot be empty"} + } + + if len(url) > 2048 { + return ValidationError{Field: "url", Message: "URL too long"} + } + + // Basic URL validation + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return ValidationError{Field: "url", Message: "must start with http:// or https://"} + } + + // Check for dangerous URL patterns + dangerousPatterns := []string{ + "javascript:", "data:", "file:", "ftp:", + "", true}, + {"too long path", string(make([]byte, 5000)), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateFilePath(tt.input) + if tt.expectError && err == nil { + t.Errorf("Expected error for input %q, but got none", tt.input) + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error for input %q: %v", tt.input, err) + } + }) + } +} + +func TestValidateCommandInput(t *testing.T) { + tests := []struct { + name string + input string + expectError bool + }{ + {"valid input", "my-service", false}, + {"empty input", "", true}, + {"command injection", "test; rm -rf /", true}, + {"pipe injection", "test | cat /etc/passwd", true}, + {"backtick injection", "test`whoami`", true}, + {"variable expansion", "test${USER}", true}, + {"too long input", string(make([]byte, 2000)), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateCommandInput(tt.input) + if tt.expectError && err == nil { + t.Errorf("Expected error for input %q, but got none", tt.input) + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error for input %q: %v", tt.input, err) + } + }) + } +} + +func TestSanitizeInput(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"clean input", "hello world", "hello world"}, + {"with newlines", "hello\nworld", "hello world"}, + {"with tabs", "hello\tworld", "hello world"}, + {"with carriage returns", "hello\rworld", "hello world"}, + {"with spaces", " hello world ", "hello world"}, + {"mixed whitespace", "\n\t hello world \r\n", "hello world"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeInput(tt.input) + if result != tt.expected { + t.Errorf("Expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestValidateK8sLabel(t *testing.T) { + tests := []struct { + name string + key string + value string + expectError bool + }{ + {"valid label", "app", "nginx", false}, + {"valid label with dash", "app-version", "1.0", false}, + {"valid label with underscore", "app_name", "nginx", false}, + {"empty key", "", "value", true}, + {"empty value", "key", "", false}, // Empty value is allowed + {"too long key", string(make([]byte, 70)), "value", true}, + {"too long value", "key", string(make([]byte, 70)), true}, + {"invalid key characters", "app/name", "nginx", true}, + {"invalid value characters", "app", "nginx/web", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateK8sLabel(tt.key, tt.value) + if tt.expectError && err == nil { + t.Errorf("Expected error for key %q, value %q, but got none", tt.key, tt.value) + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error for key %q, value %q: %v", tt.key, tt.value, err) + } + }) + } +} + +func TestValidatePromQLQuery(t *testing.T) { + tests := []struct { + name string + input string + expectError bool + }{ + {"valid query", "up{job=\"prometheus\"}", false}, + {"valid aggregation", "sum(rate(http_requests_total[5m]))", false}, + {"empty query", "", true}, + {"command injection", "up; rm -rf /", true}, + {"backtick injection", "up`whoami`", true}, + {"variable expansion", "up${USER}", true}, + {"too long query", string(make([]byte, 10000)), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePromQLQuery(tt.input) + if tt.expectError && err == nil { + t.Errorf("Expected error for input %q, but got none", tt.input) + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error for input %q: %v", tt.input, err) + } + }) + } +} + +func TestValidateYAMLContent(t *testing.T) { + tests := []struct { + name string + input string + expectError bool + }{ + {"valid YAML", "apiVersion: v1\nkind: Pod", false}, + {"empty content", "", true}, + {"python object", "!!python/object/apply", true}, + {"python import", "__import__('os').system('rm -rf /')", true}, + {"eval injection", "eval('print(1)')", true}, + {"too large content", string(make([]byte, 2*1024*1024)), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateYAMLContent(tt.input) + if tt.expectError && err == nil { + t.Errorf("Expected error for input %q, but got none", tt.input) + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error for input %q: %v", tt.input, err) + } + }) + } +} + +func TestValidateHelmReleaseName(t *testing.T) { + tests := []struct { + name string + input string + expectError bool + }{ + {"valid release name", "my-release", false}, + {"valid with numbers", "release-123", false}, + {"empty name", "", true}, + {"too long name", "this-is-a-very-long-release-name-that-exceeds-the-maximum-allowed-length-of-53-characters", true}, + {"invalid characters", "my_release", true}, + {"starts with dash", "-release", true}, + {"ends with dash", "release-", true}, + {"uppercase", "Release", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateHelmReleaseName(tt.input) + if tt.expectError && err == nil { + t.Errorf("Expected error for input %q, but got none", tt.input) + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error for input %q: %v", tt.input, err) + } + }) + } +} + +func TestValidateURL(t *testing.T) { + tests := []struct { + name string + input string + expectError bool + }{ + {"valid http URL", "http://example.com", false}, + {"valid https URL", "https://example.com/path", false}, + {"empty URL", "", true}, + {"invalid protocol", "ftp://example.com", true}, + {"javascript injection", "javascript:alert('xss')", true}, + {"data URL", "data:text/html,", true}, + {"too long URL", "https://" + string(make([]byte, 3000)), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateURL(tt.input) + if tt.expectError && err == nil { + t.Errorf("Expected error for input %q, but got none", tt.input) + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error for input %q: %v", tt.input, err) + } + }) + } +} + +func TestValidationError(t *testing.T) { + err := ValidationError{ + Field: "test_field", + Message: "test message", + } + + expected := "validation error in field 'test_field': test message" + if err.Error() != expected { + t.Errorf("Expected error message %q, got %q", expected, err.Error()) + } +} diff --git a/internal/telemetry/config.go b/internal/telemetry/config.go new file mode 100644 index 00000000..56b266e8 --- /dev/null +++ b/internal/telemetry/config.go @@ -0,0 +1,74 @@ +package telemetry + +import ( + "os" + "strconv" + "strings" + "sync" +) + +// Telemetry holds all telemetry-related configuration. +type Telemetry struct { + ServiceName string + ServiceVersion string + Environment string + Endpoint string + Protocol string + SamplingRatio float64 + Insecure bool + Disabled bool +} + +// Config holds all application configuration. +type Config struct { + Telemetry Telemetry +} + +var ( + once sync.Once + config *Config +) + +// LoadOtelCfg initializes and returns the application configuration. +func LoadOtelCfg() *Config { + once.Do(func() { + config = &Config{ + Telemetry: Telemetry{ + ServiceName: getEnv("OTEL_SERVICE_NAME", "kagent-tools"), + ServiceVersion: getEnv("OTEL_SERVICE_VERSION", "dev"), + Environment: getEnv("OTEL_ENVIRONMENT", "development"), + Endpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", ""), + Protocol: getEnv("OTEL_EXPORTER_OTLP_PROTOCOL", "auto"), + SamplingRatio: getEnvFloat("OTEL_TRACES_SAMPLER_ARG", 1.0), + Insecure: getEnvBool("OTEL_EXPORTER_OTLP_TRACES_INSECURE", false), + Disabled: getEnvBool("OTEL_SDK_DISABLED", false), + }, + } + }) + return config +} + +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +func getEnvFloat(key string, fallback float64) float64 { + if valueStr, ok := os.LookupEnv(key); ok { + if value, err := strconv.ParseFloat(valueStr, 64); err == nil { + return value + } + } + return fallback +} + +func getEnvBool(key string, fallback bool) bool { + if valueStr, ok := os.LookupEnv(key); ok { + if value, err := strconv.ParseBool(strings.ToLower(valueStr)); err == nil { + return value + } + } + return fallback +} diff --git a/internal/telemetry/config_test.go b/internal/telemetry/config_test.go new file mode 100644 index 00000000..fe6454b5 --- /dev/null +++ b/internal/telemetry/config_test.go @@ -0,0 +1,49 @@ +package telemetry + +import ( + "os" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoad(t *testing.T) { + // Reset singleton for testing + once = sync.Once{} + config = nil + + os.Setenv("OTEL_SERVICE_NAME", "test-service") + os.Setenv("OTEL_EXPORTER_OTLP_TRACES_INSECURE", "true") + defer func() { + os.Unsetenv("OTEL_SERVICE_NAME") + os.Unsetenv("OTEL_EXPORTER_OTLP_TRACES_INSECURE") + }() + + cfg := LoadOtelCfg() + assert.Equal(t, "test-service", cfg.Telemetry.ServiceName) + assert.True(t, cfg.Telemetry.Insecure) +} + +func TestLoadDefaults(t *testing.T) { + // Reset singleton for testing + once = sync.Once{} + config = nil + + cfg := LoadOtelCfg() + assert.Equal(t, "kagent-tools", cfg.Telemetry.ServiceName) + assert.False(t, cfg.Telemetry.Insecure) + assert.Equal(t, 1.0, cfg.Telemetry.SamplingRatio) +} + +func TestLoadDevelopmentSampling(t *testing.T) { + // Reset singleton for testing + once = sync.Once{} + config = nil + + os.Setenv("OTEL_ENVIRONMENT", "development") + defer os.Unsetenv("OTEL_ENVIRONMENT") + + cfg := LoadOtelCfg() + assert.Equal(t, 1.0, cfg.Telemetry.SamplingRatio) +} diff --git a/internal/telemetry/middleware.go b/internal/telemetry/middleware.go new file mode 100644 index 00000000..720a99b8 --- /dev/null +++ b/internal/telemetry/middleware.go @@ -0,0 +1,179 @@ +package telemetry + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +type ToolHandler func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + +// contextKey is used for storing HTTP context in the request context +type contextKey string + +const ( + HTTPHeadersKey contextKey = "http_headers" + TraceIDKey contextKey = "trace_id" + SpanIDKey contextKey = "span_id" +) + +// HTTPMiddleware wraps an HTTP handler to extract headers and propagate context +func HTTPMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Extract OpenTelemetry context from HTTP headers + propagator := otel.GetTextMapPropagator() + ctx = propagator.Extract(ctx, propagation.HeaderCarrier(r.Header)) + + // Store relevant HTTP headers in context for tool handlers + headers := make(map[string]string) + for name, values := range r.Header { + if len(values) > 0 { + // Store important headers for debugging/tracing + switch name { + case "X-Request-ID", "X-Correlation-ID", "X-Trace-ID", + "User-Agent", "Authorization", "X-Forwarded-For": + headers[name] = values[0] + } + } + } + + // Add headers to context + ctx = context.WithValue(ctx, HTTPHeadersKey, headers) + + // Extract trace information if available + span := trace.SpanFromContext(ctx) + if span.SpanContext().HasTraceID() { + ctx = context.WithValue(ctx, TraceIDKey, span.SpanContext().TraceID().String()) + ctx = context.WithValue(ctx, SpanIDKey, span.SpanContext().SpanID().String()) + } + + // Call next handler with enhanced context + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// ExtractHTTPHeaders retrieves HTTP headers from context +func ExtractHTTPHeaders(ctx context.Context) map[string]string { + if headers, ok := ctx.Value(HTTPHeadersKey).(map[string]string); ok { + return headers + } + return make(map[string]string) +} + +// ExtractTraceInfo retrieves trace information from context +func ExtractTraceInfo(ctx context.Context) (traceID, spanID string) { + if tid, ok := ctx.Value(TraceIDKey).(string); ok { + traceID = tid + } + if sid, ok := ctx.Value(SpanIDKey).(string); ok { + spanID = sid + } + return traceID, spanID +} + +func WithTracing(toolName string, handler ToolHandler) ToolHandler { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + tracer := otel.Tracer("kagent-tools/mcp") + + spanName := fmt.Sprintf("mcp.tool.%s", toolName) + ctx, span := tracer.Start(ctx, spanName) + defer span.End() + + // Extract HTTP headers from context and add as span attributes + headers := ExtractHTTPHeaders(ctx) + for key, value := range headers { + span.SetAttributes(attribute.String(fmt.Sprintf("http.header.%s", key), value)) + } + + // Extract parent trace information + parentTraceID, parentSpanID := ExtractTraceInfo(ctx) + if parentTraceID != "" { + span.SetAttributes( + attribute.String("http.parent_trace_id", parentTraceID), + attribute.String("http.parent_span_id", parentSpanID), + ) + } + + span.SetAttributes( + attribute.String("mcp.tool.name", toolName), + attribute.String("mcp.request.id", request.Params.Name), + ) + + if request.Params.Arguments != nil { + if argsJSON, err := json.Marshal(request.Params.Arguments); err == nil { + span.SetAttributes(attribute.String("mcp.request.arguments", string(argsJSON))) + } + } + + span.AddEvent("tool.execution.start") + startTime := time.Now() + + result, err := handler(ctx, request) + + duration := time.Since(startTime) + span.SetAttributes(attribute.Float64("mcp.tool.duration_seconds", duration.Seconds())) + + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + span.AddEvent("tool.execution.error", trace.WithAttributes( + attribute.String("error.message", err.Error()), + )) + } else { + span.SetStatus(codes.Ok, "tool execution completed successfully") + span.AddEvent("tool.execution.success") + + if result != nil { + span.SetAttributes(attribute.Bool("mcp.result.is_error", result.IsError)) + if result.Content != nil { + span.SetAttributes(attribute.Int("mcp.result.content_count", len(result.Content))) + } + } + } + + return result, err + } +} + +func StartSpan(ctx context.Context, operationName string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { + tracer := otel.Tracer("kagent-tools") + ctx, span := tracer.Start(ctx, operationName) + + if len(attrs) > 0 { + span.SetAttributes(attrs...) + } + + return ctx, span +} + +func RecordError(span trace.Span, err error, message string) { + span.RecordError(err) + span.SetStatus(codes.Error, message) +} + +func RecordSuccess(span trace.Span, message string) { + span.SetStatus(codes.Ok, message) +} + +func AddEvent(span trace.Span, name string, attrs ...attribute.KeyValue) { + span.AddEvent(name, trace.WithAttributes(attrs...)) +} + +// AdaptToolHandler adapts a telemetry.ToolHandler to a server.ToolHandlerFunc. +func AdaptToolHandler(th ToolHandler) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return th(ctx, req) + } +} diff --git a/internal/telemetry/middleware_test.go b/internal/telemetry/middleware_test.go new file mode 100644 index 00000000..bcbf494c --- /dev/null +++ b/internal/telemetry/middleware_test.go @@ -0,0 +1,801 @@ +package telemetry + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// InMemoryExporter is a simple in-memory exporter for testing +type InMemoryExporter struct { + spans []trace.ReadOnlySpan +} + +func (e *InMemoryExporter) ExportSpans(ctx context.Context, spans []trace.ReadOnlySpan) error { + e.spans = append(e.spans, spans...) + return nil +} + +func (e *InMemoryExporter) Shutdown(ctx context.Context) error { + return nil +} + +func (e *InMemoryExporter) GetSpans() []trace.ReadOnlySpan { + return e.spans +} + +// setupTracing initializes OpenTelemetry with in-memory exporter for testing +func setupTracing() (*trace.TracerProvider, *InMemoryExporter) { + exporter := &InMemoryExporter{} + provider := trace.NewTracerProvider( + trace.WithSampler(trace.AlwaysSample()), + trace.WithSpanProcessor(trace.NewSimpleSpanProcessor(exporter)), + ) + otel.SetTracerProvider(provider) + return provider, exporter +} + +func TestWithTracing(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Create a test handler + testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + textContent := mcp.NewTextContent("test response") + return &mcp.CallToolResult{ + IsError: false, + Content: []mcp.Content{textContent}, + }, nil + } + + // Wrap with tracing + tracedHandler := WithTracing("test-tool", testHandler) + + // Create test request + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test-tool", + Arguments: map[string]interface{}{ + "param1": "value1", + "param2": 42, + }, + }, + } + + // Execute the handler + result, err := tracedHandler(context.Background(), request) + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify result + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Len(t, result.Content, 1) + textContent, ok := mcp.AsTextContent(result.Content[0]) + require.True(t, ok) + assert.Equal(t, "test response", textContent.Text) + + // Verify span was created + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + span := spans[0] + assert.Equal(t, "mcp.tool.test-tool", span.Name()) + assert.Equal(t, codes.Ok, span.Status().Code) + // Note: SDK may not preserve description in test environment + // assert.Equal(t, "tool execution completed successfully", span.Status().Description) + + // Verify attributes + attributes := span.Attributes() + hasToolName := false + hasRequestID := false + hasIsError := false + hasContentCount := false + + for _, attr := range attributes { + if attr.Key == "mcp.tool.name" && attr.Value.AsString() == "test-tool" { + hasToolName = true + } + if attr.Key == "mcp.request.id" && attr.Value.AsString() == "test-tool" { + hasRequestID = true + } + if attr.Key == "mcp.result.is_error" && attr.Value.AsBool() == false { + hasIsError = true + } + if attr.Key == "mcp.result.content_count" && attr.Value.AsInt64() == 1 { + hasContentCount = true + } + } + + assert.True(t, hasToolName) + assert.True(t, hasRequestID) + assert.True(t, hasIsError) + assert.True(t, hasContentCount) + + // Verify events + events := span.Events() + assert.Len(t, events, 2) + assert.Equal(t, "tool.execution.start", events[0].Name) + assert.Equal(t, "tool.execution.success", events[1].Name) +} + +func TestWithTracingError(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Create a test handler that returns an error + testError := errors.New("test error") + testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, testError + } + + // Wrap with tracing + tracedHandler := WithTracing("test-tool", testHandler) + + // Create test request + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test-tool", + }, + } + + // Execute the handler + result, err := tracedHandler(context.Background(), request) + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify result + assert.Error(t, err) + assert.Equal(t, testError, err) + assert.Nil(t, result) + + // Verify span was created with error + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + span := spans[0] + assert.Equal(t, "mcp.tool.test-tool", span.Name()) + assert.Equal(t, codes.Error, span.Status().Code) + // Note: SDK may not preserve description in test environment + // assert.Equal(t, "test error", span.Status().Description) + + // Verify events - span.RecordError() adds an "exception" event, plus our custom events + events := span.Events() + assert.Len(t, events, 3) + assert.Equal(t, "tool.execution.start", events[0].Name) + assert.Equal(t, "exception", events[1].Name) // Added by span.RecordError() + assert.Equal(t, "tool.execution.error", events[2].Name) +} + +func TestWithTracingErrorResult(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Create a test handler that returns an error result + testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + textContent := mcp.NewTextContent("error occurred") + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{textContent}, + }, nil + } + + // Wrap with tracing + tracedHandler := WithTracing("test-tool", testHandler) + + // Create test request + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test-tool", + }, + } + + // Execute the handler + result, err := tracedHandler(context.Background(), request) + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify result + require.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + + // Verify span was created successfully (no error from handler) + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + span := spans[0] + assert.Equal(t, "mcp.tool.test-tool", span.Name()) + assert.Equal(t, codes.Ok, span.Status().Code) + + // Verify attributes + attributes := span.Attributes() + hasIsError := false + hasContentCount := false + + for _, attr := range attributes { + if attr.Key == "mcp.result.is_error" && attr.Value.AsBool() == true { + hasIsError = true + } + if attr.Key == "mcp.result.content_count" && attr.Value.AsInt64() == 1 { + hasContentCount = true + } + } + + assert.True(t, hasIsError) + assert.True(t, hasContentCount) +} + +func TestWithTracingWithArguments(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Create a test handler + testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + textContent := mcp.NewTextContent("test response") + return &mcp.CallToolResult{ + IsError: false, + Content: []mcp.Content{textContent}, + }, nil + } + + // Wrap with tracing + tracedHandler := WithTracing("test-tool", testHandler) + + // Create test request with arguments + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test-tool", + Arguments: map[string]interface{}{ + "string_param": "hello", + "number_param": 42, + "bool_param": true, + "array_param": []interface{}{"a", "b", "c"}, + "object_param": map[string]interface{}{ + "nested": "value", + }, + }, + }, + } + + // Execute the handler + result, err := tracedHandler(context.Background(), request) + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify result + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + // Verify span was created + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + span := spans[0] + assert.Equal(t, "mcp.tool.test-tool", span.Name()) + + // Verify that arguments were added as an attribute (they are JSON-encoded) + attributes := span.Attributes() + hasArguments := false + + for _, attr := range attributes { + if attr.Key == "mcp.request.arguments" { + hasArguments = true + // Arguments should be JSON-encoded + assert.NotEmpty(t, attr.Value.AsString()) + } + } + + assert.True(t, hasArguments) +} + +func TestWithTracingNilArguments(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Create a test handler + testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + textContent := mcp.NewTextContent("test response") + return &mcp.CallToolResult{ + IsError: false, + Content: []mcp.Content{textContent}, + }, nil + } + + // Wrap with tracing + tracedHandler := WithTracing("test-tool", testHandler) + + // Create test request without arguments + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test-tool", + }, + } + + // Execute the handler + result, err := tracedHandler(context.Background(), request) + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify result + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + // Verify span was created + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + span := spans[0] + assert.Equal(t, "mcp.tool.test-tool", span.Name()) +} + +func TestStartSpan(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Start a span + _, span := StartSpan(context.Background(), "test-span", + attribute.String("key1", "value1"), + attribute.Int("key2", 42), + ) + + // End the span + span.End() + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify span was created + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + resultSpan := spans[0] + assert.Equal(t, "test-span", resultSpan.Name()) +} + +func TestStartSpanNoAttributes(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Start a span without attributes + _, span := StartSpan(context.Background(), "test-span") + + // End the span + span.End() + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify span was created + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + resultSpan := spans[0] + assert.Equal(t, "test-span", resultSpan.Name()) +} + +func TestRecordError(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Start a span + _, span := StartSpan(context.Background(), "test-span") + + // Record an error + testError := errors.New("test error") + RecordError(span, testError, "test error") + + // End the span + span.End() + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify span was created with error + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + resultSpan := spans[0] + assert.Equal(t, "test-span", resultSpan.Name()) + assert.Equal(t, codes.Error, resultSpan.Status().Code) + assert.Equal(t, "test error", resultSpan.Status().Description) +} + +func TestRecordSuccess(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Start a span + _, span := StartSpan(context.Background(), "test-span") + + // Record success + RecordSuccess(span, "operation completed successfully") + + // End the span + span.End() + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify span was created with success + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + resultSpan := spans[0] + assert.Equal(t, "test-span", resultSpan.Name()) + assert.Equal(t, codes.Ok, resultSpan.Status().Code) + // Note: SDK may not preserve description in test environment + // assert.Equal(t, "operation completed successfully", resultSpan.Status().Description) +} + +func TestAddEvent(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Start a span + _, span := StartSpan(context.Background(), "test-span") + + // Add an event + AddEvent(span, "test-event", + attribute.String("event_key", "event_value"), + attribute.Int("event_num", 123), + ) + + // End the span + span.End() + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify span was created with event + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + resultSpan := spans[0] + assert.Equal(t, "test-span", resultSpan.Name()) + + // Verify event + events := resultSpan.Events() + assert.Len(t, events, 1) + assert.Equal(t, "test-event", events[0].Name) +} + +func TestAddEventNoAttributes(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Start a span + _, span := StartSpan(context.Background(), "test-span") + + // Add an event without attributes + AddEvent(span, "test-event") + + // End the span + span.End() + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify span was created with event + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + resultSpan := spans[0] + assert.Equal(t, "test-span", resultSpan.Name()) + + // Verify event + events := resultSpan.Events() + assert.Len(t, events, 1) + assert.Equal(t, "test-event", events[0].Name) +} + +func TestAdaptToolHandler(t *testing.T) { + // Create a test handler + testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + textContent := mcp.NewTextContent("test response") + return &mcp.CallToolResult{ + IsError: false, + Content: []mcp.Content{textContent}, + }, nil + } + + // Adapt the handler + adapted := AdaptToolHandler(testHandler) + + // Create test request + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test-tool", + }, + } + + // Execute the adapted handler + result, err := adapted(context.Background(), request) + + // Verify result + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Len(t, result.Content, 1) + textContent, ok := mcp.AsTextContent(result.Content[0]) + require.True(t, ok) + assert.Equal(t, "test response", textContent.Text) +} + +func TestWithTracingNilResult(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Create a test handler that returns nil result + testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + } + + // Wrap with tracing + tracedHandler := WithTracing("test-tool", testHandler) + + // Create test request + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test-tool", + }, + } + + // Execute the handler + result, err := tracedHandler(context.Background(), request) + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify result + require.NoError(t, err) + assert.Nil(t, result) + + // Verify span was created + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + span := spans[0] + assert.Equal(t, "mcp.tool.test-tool", span.Name()) + assert.Equal(t, codes.Ok, span.Status().Code) +} + +func TestWithTracingNoContent(t *testing.T) { + // Initialize OpenTelemetry + provider, exporter := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Create a test handler that returns result with no content + testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + IsError: false, + Content: []mcp.Content{}, + }, nil + } + + // Wrap with tracing + tracedHandler := WithTracing("test-tool", testHandler) + + // Create test request + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test-tool", + }, + } + + // Execute the handler + result, err := tracedHandler(context.Background(), request) + + // Force flush to ensure spans are exported + if err := provider.ForceFlush(context.Background()); err != nil { + t.Errorf("Failed to flush provider: %v", err) + } + + // Verify result + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Len(t, result.Content, 0) + + // Verify span was created + spans := exporter.GetSpans() + assert.Len(t, spans, 1) + + span := spans[0] + assert.Equal(t, "mcp.tool.test-tool", span.Name()) + assert.Equal(t, codes.Ok, span.Status().Code) + + // Verify attributes + attributes := span.Attributes() + hasContentCount := false + + for _, attr := range attributes { + if attr.Key == "mcp.result.content_count" && attr.Value.AsInt64() == 0 { + hasContentCount = true + } + } + + assert.True(t, hasContentCount) +} + +func TestWithTracingNoopTracer(t *testing.T) { + // Set up noop tracer provider + otel.SetTracerProvider(noop.NewTracerProvider()) + + // Create a test handler + testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + textContent := mcp.NewTextContent("test response") + return &mcp.CallToolResult{ + IsError: false, + Content: []mcp.Content{textContent}, + }, nil + } + + // Wrap with tracing + tracedHandler := WithTracing("test-tool", testHandler) + + // Create test request + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test-tool", + }, + } + + // Execute the handler + result, err := tracedHandler(context.Background(), request) + + // Verify result (should work normally with noop tracer) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Len(t, result.Content, 1) + textContent, ok := mcp.AsTextContent(result.Content[0]) + require.True(t, ok) + assert.Equal(t, "test response", textContent.Text) +} + +func TestWithTracingPerformance(t *testing.T) { + // Initialize OpenTelemetry + provider, _ := setupTracing() + defer func() { + if err := provider.Shutdown(context.Background()); err != nil { + t.Errorf("Failed to shutdown provider: %v", err) + } + }() + + // Create a test handler + testHandler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + textContent := mcp.NewTextContent("test response") + return &mcp.CallToolResult{ + IsError: false, + Content: []mcp.Content{textContent}, + }, nil + } + + // Wrap with tracing + tracedHandler := WithTracing("test-tool", testHandler) + + // Create test request + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "test-tool", + }, + } + + // Time execution + start := time.Now() + for i := 0; i < 100; i++ { + _, err := tracedHandler(context.Background(), request) + require.NoError(t, err) + } + duration := time.Since(start) + + // Verify performance is reasonable (should complete in less than 1 second) + assert.Less(t, duration, time.Second) +} diff --git a/internal/telemetry/tracing.go b/internal/telemetry/tracing.go new file mode 100644 index 00000000..6b6f7208 --- /dev/null +++ b/internal/telemetry/tracing.go @@ -0,0 +1,282 @@ +package telemetry + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.32.0" + "go.opentelemetry.io/otel/trace/noop" + + "github.com/kagent-dev/tools/internal/logger" +) + +// Standard OpenTelemetry environment variable names +// These follow the official OTLP specification +const ( + // Service identification + OtelServiceName = "OTEL_SERVICE_NAME" + OtelServiceVersion = "OTEL_SERVICE_VERSION" + OtelEnvironment = "OTEL_ENVIRONMENT" // Custom extension, not in official spec + + // OTLP Exporter configuration + OtelExporterOtlpEndpoint = "OTEL_EXPORTER_OTLP_ENDPOINT" + OtelExporterOtlpProtocol = "OTEL_EXPORTER_OTLP_PROTOCOL" + OtelExporterOtlpHeaders = "OTEL_EXPORTER_OTLP_HEADERS" + + // Trace-specific OTLP configuration + OtelExporterOtlpTracesInsecure = "OTEL_EXPORTER_OTLP_TRACES_INSECURE" + + // Sampling configuration + OtelTracesSamplerArg = "OTEL_TRACES_SAMPLER_ARG" + + // SDK control + OtelSdkDisabled = "OTEL_SDK_DISABLED" +) + +// OTLP Protocol constants +const ( + ProtocolGRPC = "grpc" + ProtocolHTTP = "http/protobuf" + ProtocolAuto = "auto" // Custom extension for automatic protocol detection +) + +// Standard OTLP port numbers +// These are the official OTLP default ports as per OpenTelemetry specification +const ( + DefaultOtlpGrpcPort = "4317" // Standard OTLP/gRPC port + DefaultOtlpHttpPort = "4318" // Standard OTLP/HTTP port +) + +// Default endpoint paths +const ( + DefaultHttpTracesPath = "/v1/traces" +) + +// SetupOTelSDK initializes the OpenTelemetry SDK +func SetupOTelSDK(ctx context.Context) error { + log := logger.WithContext(ctx) + cfg := LoadOtelCfg() + telemetryConfig := cfg.Telemetry + + // If tracing is disabled, set a no-op tracer provider and return. + // This prevents further initialization and ensures no traces are exported. + if cfg.Telemetry.Disabled { + otel.SetTracerProvider(noop.NewTracerProvider()) + return nil + } + + res, err := resource.New(ctx, + resource.WithDetectors(), // Detectors for cloud provider, k8s, etc. + resource.WithAttributes( + semconv.ServiceNameKey.String(telemetryConfig.ServiceName), + semconv.ServiceVersionKey.String(telemetryConfig.ServiceVersion), + attribute.String("deployment.environment", telemetryConfig.Environment), + ), + ) + if err != nil { + log.Error("failed to create resource", "error", err) + return fmt.Errorf("failed to create resource: %w", err) + } + + // Set up propagator + prop := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) + otel.SetTextMapPropagator(prop) + + exporter, err := createExporter(ctx, &telemetryConfig) + if err != nil { + log.Error("failed to create exporter", "error", err) + return fmt.Errorf("failed to create exporter: %w", err) + } + + // Set up trace provider + tracerProvider, err := newTracerProvider(ctx, &telemetryConfig, exporter, res) + if err != nil { + log.Error("failed to create tracer provider", "error", err) + return fmt.Errorf("failed to create tracer provider: %w", err) + } + otel.SetTracerProvider(tracerProvider) + + log.Info("OpenTelemetry SDK successfully initialized") + //start goroutine and wait for ctx cancellation + go func() { + <-ctx.Done() + if err := tracerProvider.Shutdown(ctx); err != nil { + log.Error("failed to shutdown tracer provider", "error", err) + } else { + log.Info("OpenTelemetry SDK shutdown successfully") + } + }() + return nil +} + +// newTracerProvider creates a new trace provider +func newTracerProvider(ctx context.Context, cfg *Telemetry, exporter sdktrace.SpanExporter, res *resource.Resource) (*sdktrace.TracerProvider, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + sampler := sdktrace.AlwaysSample() + + tp := sdktrace.NewTracerProvider( + sdktrace.WithSampler(sampler), + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + return tp, nil +} + +// createExporter creates a OTLP exporter +func createExporter(ctx context.Context, cfg *Telemetry) (sdktrace.SpanExporter, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + if cfg.Endpoint == "" { + return stdouttrace.New(stdouttrace.WithPrettyPrint()) + } + + // Determine protocol + protocol := cfg.Protocol + if protocol == ProtocolAuto || protocol == "" { + protocol = detectProtocol(cfg.Endpoint) + } + + switch strings.ToLower(protocol) { + case ProtocolGRPC: + return createGRPCExporter(ctx, cfg) + case ProtocolHTTP: + return createHTTPExporter(ctx, cfg) + default: + return nil, fmt.Errorf("unsupported protocol: %s (supported: %s, %s)", protocol, ProtocolGRPC, ProtocolHTTP) + } +} + +// detectProtocol determines the protocol based on the endpoint URL +func detectProtocol(endpoint string) string { + // Parse URL to extract port + if parsedURL, err := url.Parse(endpoint); err == nil { + port := parsedURL.Port() + if port == "" { + // Check for default ports in hostname + if strings.Contains(parsedURL.Host, ":"+DefaultOtlpGrpcPort) { + return ProtocolGRPC + } + if strings.Contains(parsedURL.Host, ":"+DefaultOtlpHttpPort) { + return ProtocolHTTP + } + } else { + switch port { + case DefaultOtlpGrpcPort: + return ProtocolGRPC + case DefaultOtlpHttpPort: + return ProtocolHTTP + } + } + } + + // Check if endpoint contains port info directly + if strings.Contains(endpoint, ":"+DefaultOtlpGrpcPort) { + return ProtocolGRPC + } + if strings.Contains(endpoint, ":"+DefaultOtlpHttpPort) { + return ProtocolHTTP + } + + // Default to HTTP for backward compatibility + return ProtocolHTTP +} + +// createGRPCExporter creates a gRPC OTLP exporter +func createGRPCExporter(ctx context.Context, cfg *Telemetry) (sdktrace.SpanExporter, error) { + opts := []otlptracegrpc.Option{ + otlptracegrpc.WithEndpoint(normalizeGRPCEndpoint(cfg.Endpoint)), + otlptracegrpc.WithTimeout(30 * time.Second), + } + + // Use insecure connection if explicitly configured + if cfg.Insecure { + opts = append(opts, otlptracegrpc.WithInsecure()) + } + + if authToken := os.Getenv(OtelExporterOtlpHeaders); authToken != "" { + opts = append(opts, otlptracegrpc.WithHeaders(parseHeaders(authToken))) + } + + return otlptracegrpc.New(ctx, opts...) +} + +// createHTTPExporter creates an HTTP OTLP exporter +func createHTTPExporter(ctx context.Context, cfg *Telemetry) (sdktrace.SpanExporter, error) { + opts := []otlptracehttp.Option{ + otlptracehttp.WithEndpointURL(normalizeHTTPEndpoint(cfg.Endpoint, cfg.Insecure)), + otlptracehttp.WithTimeout(30 * time.Second), + } + + // Use insecure connection if explicitly configured + if cfg.Insecure { + opts = append(opts, otlptracehttp.WithInsecure()) + } + + if authToken := os.Getenv(OtelExporterOtlpHeaders); authToken != "" { + opts = append(opts, otlptracehttp.WithHeaders(parseHeaders(authToken))) + } + + return otlptracehttp.New(ctx, opts...) +} + +// normalizeGRPCEndpoint normalizes the endpoint for gRPC usage +func normalizeGRPCEndpoint(endpoint string) string { + if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { + return endpoint + } + + u, err := url.Parse(endpoint) + if err != nil { + return endpoint // Should not happen with the check above, but as a safeguard + } + + return u.Host + u.Path +} + +// normalizeHTTPEndpoint normalizes the endpoint for HTTP usage +func normalizeHTTPEndpoint(endpoint string, insecure bool) string { + // Ensure we have a proper HTTP URL + if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { + // Use HTTP if insecure is true or if endpoint contains localhost/127.0.0.1/docker.internal + if insecure || strings.Contains(endpoint, "localhost") || strings.Contains(endpoint, "127.0.0.1") || strings.Contains(endpoint, "docker.internal") { + endpoint = "http://" + endpoint + } else { + endpoint = "https://" + endpoint + } + } + + // Add /v1/traces suffix if not present + if !strings.HasSuffix(endpoint, DefaultHttpTracesPath) { + endpoint = strings.TrimSuffix(endpoint, "/") + DefaultHttpTracesPath + } + + return endpoint +} + +// parseHeaders parses a comma-separated string of headers into a map +func parseHeaders(headers string) map[string]string { + headerMap := make(map[string]string) + for _, h := range strings.Split(headers, ",") { + if parts := strings.SplitN(h, "=", 2); len(parts) == 2 { + headerMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + return headerMap +} diff --git a/internal/telemetry/tracing_test.go b/internal/telemetry/tracing_test.go new file mode 100644 index 00000000..f26f3bde --- /dev/null +++ b/internal/telemetry/tracing_test.go @@ -0,0 +1,374 @@ +package telemetry + +import ( + "context" + "os" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/trace/noop" +) + +// Test protocol constants for additional test scenarios +const ( + ProtocolInvalid = "invalid" +) + +// resetConfig is a helper to reset the singleton config for tests +func resetConfig() { + once = sync.Once{} + config = nil +} + +func TestSetupOTelSDK_Disabled(t *testing.T) { + resetConfig() + ctx := context.Background() + err := os.Setenv("OTEL_SDK_DISABLED", "true") + require.NoError(t, err) + defer func() { + _ = os.Unsetenv("OTEL_SDK_DISABLED") + }() + resetConfig() + + err = SetupOTelSDK(ctx) + require.NoError(t, err) + + // In a disabled state, the tracer provider should be a no-op provider + tp := otel.GetTracerProvider() + assert.IsType(t, noop.NewTracerProvider(), tp) + + // Shutdown should be a no-op function + assert.NoError(t, err) +} + +func TestSetupOTelSDKEnabled(t *testing.T) { + resetConfig() + ctx := context.Background() + err := os.Setenv(OtelSdkDisabled, "false") + require.NoError(t, err) + defer func() { + _ = os.Unsetenv(OtelSdkDisabled) + }() + + err = SetupOTelSDK(ctx) + require.NoError(t, err) +} + +func TestNewTracerProviderDevelopment(t *testing.T) { + resetConfig() + ctx := context.Background() + res := resource.NewSchemaless() + cfg := &Telemetry{ + Environment: "development", + } + exporter, _ := stdouttrace.New() + + tp, err := newTracerProvider(ctx, cfg, exporter, res) + require.NoError(t, err) + assert.NotNil(t, tp) +} + +func TestNewTracerProviderProduction(t *testing.T) { + resetConfig() + ctx := context.Background() + res := resource.NewSchemaless() + cfg := &Telemetry{ + Environment: "production", + SamplingRatio: 0.5, + } + exporter, _ := stdouttrace.New() + + tp, err := newTracerProvider(ctx, cfg, exporter, res) + require.NoError(t, err) + assert.NotNil(t, tp) +} + +func TestCreateExporterDevelopment(t *testing.T) { + resetConfig() + ctx := context.Background() + cfg := &Telemetry{ + Environment: "development", + } + + exporter, err := createExporter(ctx, cfg) + require.NoError(t, err) + assert.NotNil(t, exporter) + assert.IsType(t, &stdouttrace.Exporter{}, exporter) +} + +func TestCreateExporterNoEndpoint(t *testing.T) { + resetConfig() + ctx := context.Background() + cfg := &Telemetry{ + Environment: "production", + } + + exporter, err := createExporter(ctx, cfg) + require.NoError(t, err) + assert.NotNil(t, exporter) + assert.IsType(t, &stdouttrace.Exporter{}, exporter) +} + +func TestCreateExporterWithEndpoint(t *testing.T) { + resetConfig() + ctx := context.Background() + cfg := &Telemetry{ + Environment: "production", + Endpoint: "http://localhost:4317", + Protocol: ProtocolAuto, + } + + exporter, err := createExporter(ctx, cfg) + require.NoError(t, err) + assert.NotNil(t, exporter) +} + +func TestCreateExporterWithInsecure(t *testing.T) { + resetConfig() + ctx := context.Background() + cfg := &Telemetry{ + Environment: "production", + Endpoint: "localhost:4317", + Insecure: true, + } + + // This should not fail, as insecure is handled by the exporters + _, err := createExporter(ctx, cfg) + require.NoError(t, err) +} + +func TestCreateExporterWithAuthHeaders(t *testing.T) { + resetConfig() + ctx := context.Background() + cfg := &Telemetry{ + Environment: "production", + Endpoint: "http://localhost:4317", + Protocol: ProtocolAuto, + } + + // Set auth header + err := os.Setenv(OtelExporterOtlpHeaders, "Authorization=Bearer token123") + require.NoError(t, err) + defer func() { + _ = os.Unsetenv(OtelExporterOtlpHeaders) + }() + + exporter, err := createExporter(ctx, cfg) + require.NoError(t, err) + assert.NotNil(t, exporter) + + // Clean up + err = exporter.Shutdown(ctx) + assert.NoError(t, err) +} + +func TestSetupOTelSDKWithCancellation(t *testing.T) { + resetConfig() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel context immediately + + err := SetupOTelSDK(ctx) + require.Error(t, err) // Expect an error due to context cancellation +} + +func TestProtocolDetection(t *testing.T) { + tests := []struct { + name string + endpoint string + expected string + }{ + {"gRPC port 4317", "localhost:4317", ProtocolGRPC}, + {"HTTP port 4318", "localhost:4318", ProtocolHTTP}, + {"gRPC port 4317 without scheme", "localhost:4317", ProtocolGRPC}, + {"HTTP port 4318 without scheme", "localhost:4318", ProtocolHTTP}, + {"gRPC with docker internal", "host.docker.internal:4317", ProtocolGRPC}, + {"HTTP with docker internal", "host.docker.internal:4318", ProtocolHTTP}, + {"No port specified", "localhost", ProtocolHTTP}, + {"Unknown port", "localhost:1234", ProtocolHTTP}, + {"HTTPS with gRPC port", "https://localhost:4317", ProtocolGRPC}, + {"HTTPS with HTTP port", "https://localhost:4318", ProtocolHTTP}, + {"gRPC with path", "localhost:4317/v1/traces", ProtocolGRPC}, + {"HTTP with path", "localhost:4318/v1/traces", ProtocolHTTP}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := detectProtocol(tt.endpoint) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEndpointNormalization(t *testing.T) { + tests := []struct { + name string + endpoint string + expected string + }{ + {"Basic gRPC endpoint", "localhost:4317", "localhost:4317"}, + {"gRPC with path", "localhost:4317/v1/traces", "localhost:4317/v1/traces"}, + {"gRPC without scheme", "localhost:4317", "localhost:4317"}, + {"gRPC with HTTPS", "https://localhost:4317", "localhost:4317"}, + {"Docker internal gRPC", "host.docker.internal:4317", "host.docker.internal:4317"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeGRPCEndpoint(tt.endpoint) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHTTPEndpointNormalization(t *testing.T) { + tests := []struct { + name string + endpoint string + insecure bool + expected string + }{ + {"Basic HTTP endpoint", "http://localhost:4318", false, "http://localhost:4318/v1/traces"}, + {"HTTP with path", "http://localhost:4318/v1/traces", false, "http://localhost:4318/v1/traces"}, + {"HTTP without scheme - secure localhost", "localhost:4318", false, "http://localhost:4318/v1/traces"}, + {"HTTP without scheme - insecure localhost", "localhost:4318", true, "http://localhost:4318/v1/traces"}, + {"HTTP with trailing slash", "http://localhost:4318/", false, "http://localhost:4318/v1/traces"}, + {"Docker internal HTTP - secure", "host.docker.internal:4318", false, "http://host.docker.internal:4318/v1/traces"}, + {"Docker internal HTTP - insecure", "host.docker.internal:4318", true, "http://host.docker.internal:4318/v1/traces"}, + {"Remote endpoint - secure", "collector.example.com:4318", false, "https://collector.example.com:4318/v1/traces"}, + {"Remote endpoint - insecure", "collector.example.com:4318", true, "http://collector.example.com:4318/v1/traces"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeHTTPEndpoint(tt.endpoint, tt.insecure) + assert.Equal(t, tt.expected, result, "HTTP endpoint normalization failed for: %s", tt.endpoint) + }) + } +} + +func TestParseHeaders(t *testing.T) { + tests := []struct { + name string + headers string + want map[string]string + }{ + {"Empty string", "", map[string]string{}}, + {"Single header", "key=value", map[string]string{"key": "value"}}, + {"Multiple headers", "key1=value1,key2=value2", map[string]string{"key1": "value1", "key2": "value2"}}, + {"Headers with spaces", " key1 = value1 , key2 = value2 ", map[string]string{"key1": "value1", "key2": "value2"}}, + {"Invalid header format", "key-value,key2", map[string]string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseHeaders(tt.headers) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCreateExporterWithProtocol(t *testing.T) { + + ctx := context.Background() + + tests := []struct { + name string + config *Telemetry + shouldError bool + description string + }{ + { + "gRPC protocol", + &Telemetry{ + Environment: "development", + Endpoint: "localhost:4317", + Protocol: ProtocolGRPC, + }, + false, + "Should create gRPC exporter", + }, + { + "HTTP protocol", + &Telemetry{ + Environment: "development", + Endpoint: "localhost:4318", + Protocol: ProtocolHTTP, + }, + false, + "Should create HTTP exporter", + }, + { + "Auto protocol with gRPC port", + &Telemetry{ + Environment: "development", + Endpoint: "localhost:4317", + Protocol: ProtocolAuto, + }, + false, + "Should auto-detect gRPC", + }, + { + "Auto protocol with HTTP port", + &Telemetry{ + Environment: "development", + Endpoint: "localhost:4318", + Protocol: ProtocolAuto, + }, + false, + "Should auto-detect HTTP", + }, + { + "gRPC protocol with insecure", + &Telemetry{ + Environment: "production", + Endpoint: "localhost:4317", + Protocol: ProtocolGRPC, + Insecure: true, + }, + false, + "Should create gRPC exporter with insecure", + }, + { + "HTTP protocol with insecure", + &Telemetry{ + Environment: "production", + Endpoint: "localhost:4318", + Protocol: ProtocolHTTP, + Insecure: true, + }, + false, + "Should create HTTP exporter with insecure", + }, + { + "Invalid protocol", + &Telemetry{ + Environment: "development", + Endpoint: "localhost:1234", + Protocol: ProtocolInvalid, + }, + true, + "Should return error for invalid protocol", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetConfig() + exporter, err := createExporter(ctx, tt.config) + if tt.shouldError { + require.Error(t, err, tt.description) + assert.Nil(t, exporter, tt.description) + } else { + require.NoError(t, err, tt.description) + assert.NotNil(t, exporter, tt.description) + err = exporter.Shutdown(ctx) + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/version/version.go b/internal/version/version.go index b43bc838..6b556e1b 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -6,7 +6,6 @@ import ( ) var ( - // These variables should be set during build time using -ldflags Version = "dev" GitCommit = "none" BuildDate = "unknown" diff --git a/pkg/argo/argo.go b/pkg/argo/argo.go index 71328c58..758a4fb2 100644 --- a/pkg/argo/argo.go +++ b/pkg/argo/argo.go @@ -13,6 +13,8 @@ import ( "strings" "time" + "github.com/kagent-dev/tools/internal/commands" + "github.com/kagent-dev/tools/internal/telemetry" "github.com/kagent-dev/tools/pkg/utils" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -25,7 +27,7 @@ func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request mcp. label := mcp.ParseString(request, "label", "app.kubernetes.io/component=rollouts-controller") cmd := []string{"get", "pods", "-n", ns, "-l", label, "-o", "jsonpath={.items[*].status.phase}"} - output, err := utils.RunCommandWithContext(ctx, "kubectl", cmd) + output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { return mcp.NewToolResultError("Error: " + err.Error()), nil } @@ -60,7 +62,8 @@ func handleVerifyArgoRolloutsControllerInstall(ctx context.Context, request mcp. } func handleVerifyKubectlPluginInstall(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - output, err := utils.RunCommandWithContext(ctx, "kubectl", []string{"argo", "rollouts", "version"}) + args := []string{"argo", "rollouts", "version"} + output, err := runArgoRolloutCommand(ctx, args) if err != nil { return mcp.NewToolResultText("Kubectl Argo Rollouts plugin is not installed: " + err.Error()), nil } @@ -72,6 +75,14 @@ func handleVerifyKubectlPluginInstall(ctx context.Context, request mcp.CallToolR return mcp.NewToolResultText(output), nil } +func runArgoRolloutCommand(ctx context.Context, args []string) (string, error) { + kubeconfigPath := utils.GetKubeconfig() + return commands.NewCommandBuilder("kubectl"). + WithArgs(args...). + WithKubeconfig(kubeconfigPath). + Execute(ctx) +} + func handlePromoteRollout(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { rolloutName := mcp.ParseString(request, "rollout_name", "") ns := mcp.ParseString(request, "namespace", "") @@ -91,7 +102,7 @@ func handlePromoteRollout(ctx context.Context, request mcp.CallToolRequest) (*mc cmd = append(cmd, "--full") } - output, err := utils.RunCommandWithContext(ctx, "kubectl", cmd) + output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { return mcp.NewToolResultError("Error promoting rollout: " + err.Error()), nil } @@ -113,7 +124,7 @@ func handlePauseRollout(ctx context.Context, request mcp.CallToolRequest) (*mcp. } cmd = append(cmd, rolloutName) - output, err := utils.RunCommandWithContext(ctx, "kubectl", cmd) + output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { return mcp.NewToolResultError("Error pausing rollout: " + err.Error()), nil } @@ -138,7 +149,7 @@ func handleSetRolloutImage(ctx context.Context, request mcp.CallToolRequest) (*m cmd = append(cmd, "-n", ns) } - output, err := utils.RunCommandWithContext(ctx, "kubectl", cmd) + output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { return mcp.NewToolResultError("Error setting rollout image: " + err.Error()), nil } @@ -188,9 +199,13 @@ func getSystemArchitecture() (string, error) { } } -func getLatestVersion() string { +func getLatestVersion(ctx context.Context) string { client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get("https://api.github.com/repos/argoproj-labs/rollouts-plugin-trafficrouter-gatewayapi/releases/latest") + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/repos/argoproj-labs/rollouts-plugin-trafficrouter-gatewayapi/releases/latest", nil) + if err != nil { + return "0.5.0" // Default version + } + resp, err := client.Do(req) if err != nil { return "0.5.0" // Default version } @@ -210,7 +225,7 @@ func getLatestVersion() string { return "0.5.0" } -func configureGatewayPlugin(version, namespace string) GatewayPluginStatus { +func configureGatewayPlugin(ctx context.Context, version, namespace string) GatewayPluginStatus { arch, err := getSystemArchitecture() if err != nil { return GatewayPluginStatus{ @@ -220,7 +235,7 @@ func configureGatewayPlugin(version, namespace string) GatewayPluginStatus { } if version == "" { - version = getLatestVersion() + version = getLatestVersion(ctx) } configMap := fmt.Sprintf(`apiVersion: v1 @@ -253,11 +268,12 @@ data: tmpFile.Close() // Apply the ConfigMap - _, err = utils.RunCommandWithContext(context.Background(), "kubectl", []string{"apply", "-f", tmpFile.Name()}) + cmdArgs := []string{"apply", "-f", tmpFile.Name()} + output, err := runArgoRolloutCommand(ctx, cmdArgs) if err != nil { return GatewayPluginStatus{ Installed: false, - ErrorMessage: fmt.Sprintf("Failed to configure Gateway API plugin: %s", err.Error()), + ErrorMessage: fmt.Sprintf("Error applying Gateway API plugin config: %s. Output: %s", err.Error(), output), } } @@ -276,7 +292,7 @@ func handleVerifyGatewayPlugin(ctx context.Context, request mcp.CallToolRequest) // Check if ConfigMap exists and is configured cmd := []string{"get", "configmap", "argo-rollouts-config", "-n", namespace, "-o", "yaml"} - output, err := utils.RunCommandWithContext(ctx, "kubectl", cmd) + output, err := runArgoRolloutCommand(ctx, cmd) if err == nil && strings.Contains(output, "argoproj-labs/gatewayAPI") { status := GatewayPluginStatus{ Installed: true, @@ -294,22 +310,17 @@ func handleVerifyGatewayPlugin(ctx context.Context, request mcp.CallToolRequest) } // Configure plugin - status := configureGatewayPlugin(version, namespace) + status := configureGatewayPlugin(ctx, version, namespace) return mcp.NewToolResultText(status.String()), nil } func handleCheckPluginLogs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { namespace := mcp.ParseString(request, "namespace", "argo-rollouts") - timeoutStr := mcp.ParseString(request, "timeout", "60") - - // Parse timeout (for potential future use) - _, err := strconv.Atoi(timeoutStr) - if err != nil { - // Use default timeout of 60 if parsing fails - } + // timeout parameter is parsed but not used currently + _ = mcp.ParseString(request, "timeout", "60") cmd := []string{"logs", "-n", namespace, "-l", "app.kubernetes.io/name=argo-rollouts", "--tail", "100"} - output, err := utils.RunCommandWithContext(ctx, "kubectl", cmd) + output, err := runArgoRolloutCommand(ctx, cmd) if err != nil { status := GatewayPluginStatus{ Installed: false, @@ -343,47 +354,78 @@ func handleCheckPluginLogs(ctx context.Context, request mcp.CallToolRequest) (*m return mcp.NewToolResultText(status.String()), nil } -func RegisterArgoTools(s *server.MCPServer) { +func handleListRollouts(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ns := mcp.ParseString(request, "namespace", "argo-rollouts") + tt := mcp.ParseString(request, "type", "rollouts") + + cmd := []string{"argo", "rollouts", "list", tt} + if ns != "" { + cmd = append(cmd, "-n", ns) + } + + output, err := runArgoRolloutCommand(ctx, cmd) + if err != nil { + return mcp.NewToolResultError("Error listing rollouts: " + err.Error()), nil + } + + if strings.HasPrefix(output, "Error") { + return mcp.NewToolResultText(output), nil + } + + return mcp.NewToolResultText(output), nil +} + +func RegisterTools(s *server.MCPServer, readOnly bool) { + // Read-only tools - always registered s.AddTool(mcp.NewTool("argo_verify_argo_rollouts_controller_install", mcp.WithDescription("Verify that the Argo Rollouts controller is installed and running"), mcp.WithString("namespace", mcp.Description("The namespace where Argo Rollouts is installed")), mcp.WithString("label", mcp.Description("The label of the Argo Rollouts controller pods")), - ), handleVerifyArgoRolloutsControllerInstall) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_verify_argo_rollouts_controller_install", handleVerifyArgoRolloutsControllerInstall))) s.AddTool(mcp.NewTool("argo_verify_kubectl_plugin_install", mcp.WithDescription("Verify that the kubectl Argo Rollouts plugin is installed"), - ), handleVerifyKubectlPluginInstall) - - s.AddTool(mcp.NewTool("argo_promote_rollout", - mcp.WithDescription("Promote a paused rollout to the next step"), - mcp.WithString("rollout_name", mcp.Description("The name of the rollout to promote"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the rollout")), - mcp.WithString("full", mcp.Description("Promote the rollout to the final step")), - ), handlePromoteRollout) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_verify_kubectl_plugin_install", handleVerifyKubectlPluginInstall))) - s.AddTool(mcp.NewTool("argo_pause_rollout", - mcp.WithDescription("Pause a rollout"), - mcp.WithString("rollout_name", mcp.Description("The name of the rollout to pause"), mcp.Required()), + s.AddTool(mcp.NewTool("argo_rollouts_list", + mcp.WithDescription("List rollouts or experiments"), mcp.WithString("namespace", mcp.Description("The namespace of the rollout")), - ), handlePauseRollout) - - s.AddTool(mcp.NewTool("argo_set_rollout_image", - mcp.WithDescription("Set the image of a rollout"), - mcp.WithString("rollout_name", mcp.Description("The name of the rollout to set the image for"), mcp.Required()), - mcp.WithString("container_image", mcp.Description("The container image to set for the rollout"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the rollout")), - ), handleSetRolloutImage) - - s.AddTool(mcp.NewTool("argo_verify_gateway_plugin", - mcp.WithDescription("Verify the installation status of the Argo Rollouts Gateway API plugin"), - mcp.WithString("version", mcp.Description("The version of the plugin to check")), - mcp.WithString("namespace", mcp.Description("The namespace for the plugin resources")), - mcp.WithString("should_install", mcp.Description("Whether to install the plugin if not found")), - ), handleVerifyGatewayPlugin) + mcp.WithString("type", mcp.Description("What to list: rollouts or experiments"), mcp.DefaultString("rollouts")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_rollouts_list", handleListRollouts))) s.AddTool(mcp.NewTool("argo_check_plugin_logs", mcp.WithDescription("Check the logs of the Argo Rollouts Gateway API plugin"), mcp.WithString("namespace", mcp.Description("The namespace of the plugin resources")), mcp.WithString("timeout", mcp.Description("Timeout for log collection in seconds")), - ), handleCheckPluginLogs) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_check_plugin_logs", handleCheckPluginLogs))) + + // Write tools - only registered when not in read-only mode + if !readOnly { + s.AddTool(mcp.NewTool("argo_promote_rollout", + mcp.WithDescription("Promote a paused rollout to the next step"), + mcp.WithString("rollout_name", mcp.Description("The name of the rollout to promote"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace of the rollout")), + mcp.WithString("full", mcp.Description("Promote the rollout to the final step")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_promote_rollout", handlePromoteRollout))) + + s.AddTool(mcp.NewTool("argo_pause_rollout", + mcp.WithDescription("Pause a rollout"), + mcp.WithString("rollout_name", mcp.Description("The name of the rollout to pause"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace of the rollout")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_pause_rollout", handlePauseRollout))) + + s.AddTool(mcp.NewTool("argo_set_rollout_image", + mcp.WithDescription("Set the image of a rollout"), + mcp.WithString("rollout_name", mcp.Description("The name of the rollout to set the image for"), mcp.Required()), + mcp.WithString("container_image", mcp.Description("The container image to set for the rollout"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace of the rollout")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_set_rollout_image", handleSetRolloutImage))) + + s.AddTool(mcp.NewTool("argo_verify_gateway_plugin", + mcp.WithDescription("Verify the installation status of the Argo Rollouts Gateway API plugin"), + mcp.WithString("version", mcp.Description("The version of the plugin to check")), + mcp.WithString("namespace", mcp.Description("The namespace for the plugin resources")), + mcp.WithString("should_install", mcp.Description("Whether to install the plugin if not found")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("argo_verify_gateway_plugin", handleVerifyGatewayPlugin))) + } } diff --git a/pkg/argo/argo_test.go b/pkg/argo/argo_test.go index 4a80823c..3af620f2 100644 --- a/pkg/argo/argo_test.go +++ b/pkg/argo/argo_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/kagent-dev/tools/pkg/utils" + "github.com/kagent-dev/tools/internal/cmd" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,11 +27,11 @@ func getResultText(result *mcp.CallToolResult) string { // Test Argo Rollouts Promote func TestHandlePromoteRollout(t *testing.T) { t.Run("promote rollout basic", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `rollout "myapp" promoted` mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "myapp"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -43,10 +43,7 @@ func TestHandlePromoteRollout(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) - - // Verify the expected output - content := getResultText(result) - assert.Contains(t, content, "promoted") + assert.Contains(t, getResultText(result), "promoted") // Verify the correct command was called callLog := mock.GetCallLog() @@ -56,11 +53,11 @@ func TestHandlePromoteRollout(t *testing.T) { }) t.Run("promote rollout with namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `rollout "myapp" promoted` mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "-n", "production", "myapp"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -81,11 +78,11 @@ func TestHandlePromoteRollout(t *testing.T) { }) t.Run("promote rollout with full flag", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `rollout "myapp" fully promoted` mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "myapp", "--full"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -98,7 +95,7 @@ func TestHandlePromoteRollout(t *testing.T) { assert.NoError(t, err) assert.False(t, result.IsError) - // Verify the correct command was called with --full flag + // Verify the correct command was called with full flag callLog := mock.GetCallLog() require.Len(t, callLog, 1) assert.Equal(t, "kubectl", callLog[0].Command) @@ -106,8 +103,8 @@ func TestHandlePromoteRollout(t *testing.T) { }) t.Run("missing required parameters", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -125,9 +122,9 @@ func TestHandlePromoteRollout(t *testing.T) { }) t.Run("kubectl command failure", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() mock.AddCommandString("kubectl", []string{"argo", "rollouts", "promote", "myapp"}, "", assert.AnError) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -145,11 +142,11 @@ func TestHandlePromoteRollout(t *testing.T) { // Test Argo Rollouts Pause func TestHandlePauseRollout(t *testing.T) { t.Run("pause rollout basic", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `rollout "myapp" paused` mock.AddCommandString("kubectl", []string{"argo", "rollouts", "pause", "myapp"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -174,11 +171,11 @@ func TestHandlePauseRollout(t *testing.T) { }) t.Run("pause rollout with namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `rollout "myapp" paused` mock.AddCommandString("kubectl", []string{"argo", "rollouts", "pause", "-n", "production", "myapp"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -199,8 +196,8 @@ func TestHandlePauseRollout(t *testing.T) { }) t.Run("missing required parameters", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -221,11 +218,11 @@ func TestHandlePauseRollout(t *testing.T) { // Test Argo Rollouts Set Image func TestHandleSetRolloutImage(t *testing.T) { t.Run("set rollout image basic", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `rollout "myapp" image updated` mock.AddCommandString("kubectl", []string{"argo", "rollouts", "set", "image", "myapp", "nginx:latest"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -251,11 +248,11 @@ func TestHandleSetRolloutImage(t *testing.T) { }) t.Run("set rollout image with namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `rollout "myapp" image updated` mock.AddCommandString("kubectl", []string{"argo", "rollouts", "set", "image", "myapp", "nginx:1.20", "-n", "production"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -277,8 +274,8 @@ func TestHandleSetRolloutImage(t *testing.T) { }) t.Run("missing rollout_name parameter", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -297,8 +294,8 @@ func TestHandleSetRolloutImage(t *testing.T) { }) t.Run("missing container_image parameter", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -334,7 +331,7 @@ func TestGetSystemArchitecture(t *testing.T) { } func TestGetLatestVersion(t *testing.T) { - version := getLatestVersion() + version := getLatestVersion(context.Background()) if version == "" { t.Error("Expected non-empty version") } @@ -367,11 +364,11 @@ func TestGatewayPluginStatus(t *testing.T) { // Test Verify Gateway Plugin func TestHandleVerifyGatewayPlugin(t *testing.T) { t.Run("verify gateway plugin without install", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `gateway-api-plugin not found` mock.AddCommandString("kubectl", []string{"get", "configmap", "argo-rollouts-config", "-n", "argo-rollouts", "-o", "yaml"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -394,11 +391,11 @@ func TestHandleVerifyGatewayPlugin(t *testing.T) { }) t.Run("verify gateway plugin with custom namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `gateway-api-plugin-abc123` mock.AddCommandString("kubectl", []string{"get", "configmap", "argo-rollouts-config", "-n", "custom-namespace", "-o", "yaml"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -423,11 +420,11 @@ func TestHandleVerifyGatewayPlugin(t *testing.T) { // Test Verify Argo Rollouts Controller Install func TestHandleVerifyArgoRolloutsControllerInstall(t *testing.T) { t.Run("verify controller install", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `argo-rollouts-controller-manager-abc123` mock.AddCommandString("kubectl", []string{"get", "pods", "-l", "app.kubernetes.io/name=argo-rollouts", "-n", "argo-rollouts", "-o", "jsonpath={.items[*].metadata.name}"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} result, err := handleVerifyArgoRolloutsControllerInstall(ctx, request) @@ -444,11 +441,11 @@ func TestHandleVerifyArgoRolloutsControllerInstall(t *testing.T) { }) t.Run("verify controller install with custom namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `argo-rollouts-controller-manager-abc123` mock.AddCommandString("kubectl", []string{"get", "pods", "-l", "app.kubernetes.io/name=argo-rollouts", "-n", "custom-argo", "-o", "jsonpath={.items[*].metadata.name}"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -469,11 +466,11 @@ func TestHandleVerifyArgoRolloutsControllerInstall(t *testing.T) { }) t.Run("verify controller install with custom label", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `argo-rollouts-controller-manager-abc123` mock.AddCommandString("kubectl", []string{"get", "pods", "-l", "app=custom-rollouts", "-n", "argo-rollouts", "-o", "jsonpath={.items[*].metadata.name}"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -497,19 +494,19 @@ func TestHandleVerifyArgoRolloutsControllerInstall(t *testing.T) { // Test Verify Kubectl Plugin Install func TestHandleVerifyKubectlPluginInstall(t *testing.T) { t.Run("verify kubectl plugin install", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `kubectl-argo-rollouts` mock.AddCommandString("kubectl", []string{"argo", "rollouts", "version"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} result, err := handleVerifyKubectlPluginInstall(ctx, request) assert.NoError(t, err) - assert.NotNil(t, result) + assert.False(t, result.IsError) - // Verify kubectl command was called + // Verify the correct command was called callLog := mock.GetCallLog() require.Len(t, callLog, 1) assert.Equal(t, "kubectl", callLog[0].Command) @@ -517,9 +514,9 @@ func TestHandleVerifyKubectlPluginInstall(t *testing.T) { }) t.Run("kubectl plugin command failure", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() mock.AddCommandString("kubectl", []string{"plugin", "list"}, "", assert.AnError) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} result, err := handleVerifyKubectlPluginInstall(ctx, request) diff --git a/pkg/cilium/cilium.go b/pkg/cilium/cilium.go index 9fdbfe50..b92a8f1c 100644 --- a/pkg/cilium/cilium.go +++ b/pkg/cilium/cilium.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/kagent-dev/tools/internal/commands" + "github.com/kagent-dev/tools/internal/telemetry" "github.com/kagent-dev/tools/pkg/utils" "github.com/mark3labs/mcp-go/mcp" @@ -12,7 +14,11 @@ import ( ) func runCiliumCliWithContext(ctx context.Context, args ...string) (string, error) { - return utils.RunCommandWithContext(ctx, "cilium", args) + kubeconfigPath := utils.GetKubeconfig() + return commands.NewCommandBuilder("cilium"). + WithArgs(args...). + WithKubeconfig(kubeconfigPath). + Execute(ctx) } func handleCiliumStatusAndVersion(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -195,68 +201,68 @@ func handleToggleClusterMesh(ctx context.Context, request mcp.CallToolRequest) ( return mcp.NewToolResultText(output), nil } -func RegisterCiliumTools(s *server.MCPServer) { - // Register debug tools - RegisterCiliumDbgTools(s) - - // Register main Cilium tools +func RegisterTools(s *server.MCPServer, readOnly bool) { + // Read-only tools - always registered s.AddTool(mcp.NewTool("cilium_status_and_version", mcp.WithDescription("Get the status and version of Cilium installation"), - ), handleCiliumStatusAndVersion) - - s.AddTool(mcp.NewTool("cilium_upgrade_cilium", - mcp.WithDescription("Upgrade Cilium on the cluster"), - mcp.WithString("cluster_name", mcp.Description("The name of the cluster to upgrade Cilium on")), - mcp.WithString("datapath_mode", mcp.Description("The datapath mode to use for Cilium (tunnel, native, aws-eni, gke, azure, aks-byocni)")), - ), handleUpgradeCilium) - - s.AddTool(mcp.NewTool("cilium_install_cilium", - mcp.WithDescription("Install Cilium on the cluster"), - mcp.WithString("cluster_name", mcp.Description("The name of the cluster to install Cilium on")), - mcp.WithString("cluster_id", mcp.Description("The ID of the cluster to install Cilium on")), - mcp.WithString("datapath_mode", mcp.Description("The datapath mode to use for Cilium (tunnel, native, aws-eni, gke, azure, aks-byocni)")), - ), handleInstallCilium) - - s.AddTool(mcp.NewTool("cilium_uninstall_cilium", - mcp.WithDescription("Uninstall Cilium from the cluster"), - ), handleUninstallCilium) - - s.AddTool(mcp.NewTool("cilium_connect_to_remote_cluster", - mcp.WithDescription("Connect to a remote cluster for cluster mesh"), - mcp.WithString("cluster_name", mcp.Description("The name of the destination cluster"), mcp.Required()), - mcp.WithString("context", mcp.Description("The kubectl context for the destination cluster")), - ), handleConnectToRemoteCluster) - - s.AddTool(mcp.NewTool("cilium_disconnect_remote_cluster", - mcp.WithDescription("Disconnect from a remote cluster"), - mcp.WithString("cluster_name", mcp.Description("The name of the destination cluster"), mcp.Required()), - ), handleDisconnectRemoteCluster) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_status_and_version", handleCiliumStatusAndVersion))) s.AddTool(mcp.NewTool("cilium_list_bgp_peers", mcp.WithDescription("List BGP peers"), - ), handleListBGPPeers) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_bgp_peers", handleListBGPPeers))) s.AddTool(mcp.NewTool("cilium_list_bgp_routes", mcp.WithDescription("List BGP routes"), - ), handleListBGPRoutes) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_bgp_routes", handleListBGPRoutes))) s.AddTool(mcp.NewTool("cilium_show_cluster_mesh_status", mcp.WithDescription("Show cluster mesh status"), - ), handleShowClusterMeshStatus) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_cluster_mesh_status", handleShowClusterMeshStatus))) s.AddTool(mcp.NewTool("cilium_show_features_status", mcp.WithDescription("Show Cilium features status"), - ), handleShowFeaturesStatus) - - s.AddTool(mcp.NewTool("cilium_toggle_hubble", - mcp.WithDescription("Enable or disable Hubble"), - mcp.WithString("enable", mcp.Description("Set to 'true' to enable, 'false' to disable")), - ), handleToggleHubble) - - s.AddTool(mcp.NewTool("cilium_toggle_cluster_mesh", - mcp.WithDescription("Enable or disable cluster mesh"), - mcp.WithString("enable", mcp.Description("Set to 'true' to enable, 'false' to disable")), - ), handleToggleClusterMesh) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_features_status", handleShowFeaturesStatus))) + + // Write tools - only registered when write operations are enabled + if !readOnly { + s.AddTool(mcp.NewTool("cilium_upgrade_cilium", + mcp.WithDescription("Upgrade Cilium on the cluster"), + mcp.WithString("cluster_name", mcp.Description("The name of the cluster to upgrade Cilium on")), + mcp.WithString("datapath_mode", mcp.Description("The datapath mode to use for Cilium (tunnel, native, aws-eni, gke, azure, aks-byocni)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_upgrade_cilium", handleUpgradeCilium))) + + s.AddTool(mcp.NewTool("cilium_install_cilium", + mcp.WithDescription("Install Cilium on the cluster"), + mcp.WithString("cluster_name", mcp.Description("The name of the cluster to install Cilium on")), + mcp.WithString("cluster_id", mcp.Description("The ID of the cluster to install Cilium on")), + mcp.WithString("datapath_mode", mcp.Description("The datapath mode to use for Cilium (tunnel, native, aws-eni, gke, azure, aks-byocni)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_install_cilium", handleInstallCilium))) + + s.AddTool(mcp.NewTool("cilium_uninstall_cilium", + mcp.WithDescription("Uninstall Cilium from the cluster"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_uninstall_cilium", handleUninstallCilium))) + + s.AddTool(mcp.NewTool("cilium_connect_to_remote_cluster", + mcp.WithDescription("Connect to a remote cluster for cluster mesh"), + mcp.WithString("cluster_name", mcp.Description("The name of the destination cluster"), mcp.Required()), + mcp.WithString("context", mcp.Description("The kubectl context for the destination cluster")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_connect_to_remote_cluster", handleConnectToRemoteCluster))) + + s.AddTool(mcp.NewTool("cilium_disconnect_remote_cluster", + mcp.WithDescription("Disconnect from a remote cluster"), + mcp.WithString("cluster_name", mcp.Description("The name of the destination cluster"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_disconnect_remote_cluster", handleDisconnectRemoteCluster))) + + s.AddTool(mcp.NewTool("cilium_toggle_hubble", + mcp.WithDescription("Enable or disable Hubble"), + mcp.WithString("enable", mcp.Description("Set to 'true' to enable, 'false' to disable")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_toggle_hubble", handleToggleHubble))) + + s.AddTool(mcp.NewTool("cilium_toggle_cluster_mesh", + mcp.WithDescription("Enable or disable cluster mesh"), + mcp.WithString("enable", mcp.Description("Set to 'true' to enable, 'false' to disable")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_toggle_cluster_mesh", handleToggleClusterMesh))) + } // Add tools that are also needed by cilium-manager agent s.AddTool(mcp.NewTool("cilium_get_daemon_status", @@ -269,12 +275,12 @@ func RegisterCiliumTools(s *server.MCPServer) { mcp.WithString("show_all_redirects", mcp.Description("Whether to show all redirects")), mcp.WithString("brief", mcp.Description("Whether to show a brief status")), mcp.WithString("node_name", mcp.Description("The name of the node to get the daemon status for")), - ), handleGetDaemonStatus) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_daemon_status", handleGetDaemonStatus))) s.AddTool(mcp.NewTool("cilium_get_endpoints_list", mcp.WithDescription("Get the list of all endpoints in the cluster"), mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoints list for")), - ), handleGetEndpointsList) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_endpoints_list", handleGetEndpointsList))) s.AddTool(mcp.NewTool("cilium_get_endpoint_details", mcp.WithDescription("List the details of an endpoint in the cluster"), @@ -282,7 +288,7 @@ func RegisterCiliumTools(s *server.MCPServer) { mcp.WithString("labels", mcp.Description("The labels of the endpoint to get details for")), mcp.WithString("output_format", mcp.Description("The output format of the endpoint details (json, yaml, jsonpath)")), mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint details for")), - ), handleGetEndpointDetails) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_endpoint_details", handleGetEndpointDetails))) s.AddTool(mcp.NewTool("cilium_show_configuration_options", mcp.WithDescription("Show Cilium configuration options"), @@ -290,70 +296,323 @@ func RegisterCiliumTools(s *server.MCPServer) { mcp.WithString("list_read_only", mcp.Description("Whether to list read-only configuration options")), mcp.WithString("list_options", mcp.Description("Whether to list options")), mcp.WithString("node_name", mcp.Description("The name of the node to show the configuration options for")), - ), handleShowConfigurationOptions) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_configuration_options", handleShowConfigurationOptions))) - s.AddTool(mcp.NewTool("cilium_toggle_configuration_option", - mcp.WithDescription("Toggle a Cilium configuration option"), - mcp.WithString("option", mcp.Description("The option to toggle"), mcp.Required()), - mcp.WithString("value", mcp.Description("The value to set the option to (true/false)"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to toggle the configuration option for")), - ), handleToggleConfigurationOption) + // Write tool - toggle_configuration_option + if !readOnly { + s.AddTool(mcp.NewTool("cilium_toggle_configuration_option", + mcp.WithDescription("Toggle a Cilium configuration option"), + mcp.WithString("option", mcp.Description("The option to toggle"), mcp.Required()), + mcp.WithString("value", mcp.Description("The value to set the option to (true/false)"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to toggle the configuration option for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_toggle_configuration_option", handleToggleConfigurationOption))) + } s.AddTool(mcp.NewTool("cilium_list_services", mcp.WithDescription("List services for the cluster"), mcp.WithString("show_cluster_mesh_affinity", mcp.Description("Whether to show cluster mesh affinity")), mcp.WithString("node_name", mcp.Description("The name of the node to get the services for")), - ), handleListServices) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_services", handleListServices))) s.AddTool(mcp.NewTool("cilium_get_service_information", mcp.WithDescription("Get information about a service in the cluster"), mcp.WithString("service_id", mcp.Description("The ID of the service to get information about"), mcp.Required()), mcp.WithString("node_name", mcp.Description("The name of the node to get the service information for")), - ), handleGetServiceInformation) - - s.AddTool(mcp.NewTool("cilium_update_service", - mcp.WithDescription("Update a service in the cluster"), - mcp.WithString("backend_weights", mcp.Description("The backend weights to update the service with")), - mcp.WithString("backends", mcp.Description("The backends to update the service with"), mcp.Required()), - mcp.WithString("frontend", mcp.Description("The frontend to update the service with"), mcp.Required()), - mcp.WithString("id", mcp.Description("The ID of the service to update"), mcp.Required()), - mcp.WithString("k8s_cluster_internal", mcp.Description("Whether to update the k8s cluster internal flag")), - mcp.WithString("k8s_ext_traffic_policy", mcp.Description("The k8s ext traffic policy to update the service with")), - mcp.WithString("k8s_external", mcp.Description("Whether to update the k8s external flag")), - mcp.WithString("k8s_host_port", mcp.Description("Whether to update the k8s host port flag")), - mcp.WithString("k8s_int_traffic_policy", mcp.Description("The k8s int traffic policy to update the service with")), - mcp.WithString("k8s_load_balancer", mcp.Description("Whether to update the k8s load balancer flag")), - mcp.WithString("k8s_node_port", mcp.Description("Whether to update the k8s node port flag")), - mcp.WithString("local_redirect", mcp.Description("Whether to update the local redirect flag")), - mcp.WithString("protocol", mcp.Description("The protocol to update the service with")), - mcp.WithString("states", mcp.Description("The states to update the service with")), - mcp.WithString("node_name", mcp.Description("The name of the node to update the service on")), - ), handleUpdateService) -} + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_service_information", handleGetServiceInformation))) + + // Write tools - service management + if !readOnly { + s.AddTool(mcp.NewTool("cilium_update_service", + mcp.WithDescription("Update a service in the cluster"), + mcp.WithString("backend_weights", mcp.Description("The backend weights to update the service with")), + mcp.WithString("backends", mcp.Description("The backends to update the service with"), mcp.Required()), + mcp.WithString("frontend", mcp.Description("The frontend to update the service with"), mcp.Required()), + mcp.WithString("id", mcp.Description("The ID of the service to update"), mcp.Required()), + mcp.WithString("k8s_cluster_internal", mcp.Description("Whether to update the k8s cluster internal flag")), + mcp.WithString("k8s_ext_traffic_policy", mcp.Description("The k8s ext traffic policy to update the service with")), + mcp.WithString("k8s_external", mcp.Description("Whether to update the k8s external flag")), + mcp.WithString("k8s_host_port", mcp.Description("Whether to update the k8s host port flag")), + mcp.WithString("k8s_int_traffic_policy", mcp.Description("The k8s int traffic policy to update the service with")), + mcp.WithString("k8s_load_balancer", mcp.Description("Whether to update the k8s load balancer flag")), + mcp.WithString("k8s_node_port", mcp.Description("Whether to update the k8s node port flag")), + mcp.WithString("local_redirect", mcp.Description("Whether to update the local redirect flag")), + mcp.WithString("protocol", mcp.Description("The protocol to update the service with")), + mcp.WithString("states", mcp.Description("The states to update the service with")), + mcp.WithString("node_name", mcp.Description("The name of the node to update the service on")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_update_service", handleUpdateService))) + + s.AddTool(mcp.NewTool("cilium_delete_service", + mcp.WithDescription("Delete a service from the cluster"), + mcp.WithString("service_id", mcp.Description("The ID of the service to delete")), + mcp.WithString("all", mcp.Description("Whether to delete all services (true/false)")), + mcp.WithString("node_name", mcp.Description("The name of the node to delete the service from")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_delete_service", handleDeleteService))) + } + + // Debug tools (previously in RegisterCiliumDbgTools) + s.AddTool(mcp.NewTool("cilium_get_endpoint_details", + mcp.WithDescription("List the details of an endpoint in the cluster"), + mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to get details for")), + mcp.WithString("labels", mcp.Description("The labels of the endpoint to get details for")), + mcp.WithString("output_format", mcp.Description("The output format of the endpoint details (json, yaml, jsonpath)")), + mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint details for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_endpoint_details", handleGetEndpointDetails))) -// -- Debug Tools -- + s.AddTool(mcp.NewTool("cilium_get_endpoint_logs", + mcp.WithDescription("Get the logs of an endpoint in the cluster"), + mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to get logs for"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint logs for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_endpoint_logs", handleGetEndpointLogs))) -func getCiliumPodName(nodeName string) (string, error) { - return getCiliumPodNameWithContext(context.Background(), nodeName) -} + s.AddTool(mcp.NewTool("cilium_get_endpoint_health", + mcp.WithDescription("Get the health of an endpoint in the cluster"), + mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to get health for"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint health for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_endpoint_health", handleGetEndpointHealth))) + + // Write tools - endpoint management + if !readOnly { + s.AddTool(mcp.NewTool("cilium_manage_endpoint_labels", + mcp.WithDescription("Manage the labels (add or delete) of an endpoint in the cluster"), + mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to manage labels for"), mcp.Required()), + mcp.WithString("labels", mcp.Description("Space-separated labels to manage (e.g., 'key1=value1 key2=value2')"), mcp.Required()), + mcp.WithString("action", mcp.Description("The action to perform on the labels (add or delete)"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to manage the endpoint labels on")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_manage_endpoint_labels", handleManageEndpointLabels))) + + s.AddTool(mcp.NewTool("cilium_manage_endpoint_config", + mcp.WithDescription("Manage the configuration of an endpoint in the cluster"), + mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to manage configuration for"), mcp.Required()), + mcp.WithString("config", mcp.Description("The configuration to manage for the endpoint provided as a space-separated list of key-value pairs (e.g. 'DropNotification=false TraceNotification=false')"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to manage the endpoint configuration on")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_manage_endpoint_config", handleManageEndpointConfiguration))) + + s.AddTool(mcp.NewTool("cilium_disconnect_endpoint", + mcp.WithDescription("Disconnect an endpoint from the network"), + mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to disconnect"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to disconnect the endpoint from")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_disconnect_endpoint", handleDisconnectEndpoint))) + } -func getCiliumPodNameWithContext(ctx context.Context, nodeName string) (string, error) { - args := []string{"get", "pod", "-l", "k8s-app=cilium", "-o", "name", "-n", "kube-system"} - if nodeName != "" { - args = append(args, "--field-selector", "spec.nodeName="+nodeName) + s.AddTool(mcp.NewTool("cilium_list_identities", + mcp.WithDescription("List all identities in the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to list the identities for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_identities", handleListIdentities))) + + s.AddTool(mcp.NewTool("cilium_get_identity_details", + mcp.WithDescription("Get the details of an identity in the cluster"), + mcp.WithString("identity_id", mcp.Description("The ID of the identity to get details for"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to get the identity details for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_identity_details", handleGetIdentityDetails))) + + s.AddTool(mcp.NewTool("cilium_request_debugging_information", + mcp.WithDescription("Request debugging information for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the debugging information for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_request_debugging_information", handleRequestDebuggingInformation))) + + s.AddTool(mcp.NewTool("cilium_display_encryption_state", + mcp.WithDescription("Display the encryption state for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the encryption state for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_display_encryption_state", handleDisplayEncryptionState))) + + // Write tool - flush_ipsec_state + if !readOnly { + s.AddTool(mcp.NewTool("cilium_flush_ipsec_state", + mcp.WithDescription("Flush the IPsec state for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to flush the IPsec state for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_flush_ipsec_state", handleFlushIPsecState))) } - podName, err := utils.RunCommandWithContext(ctx, "kubectl", args) - if err != nil { - return "", fmt.Errorf("failed to get cilium pod name: %v", err) + + s.AddTool(mcp.NewTool("cilium_list_envoy_config", + mcp.WithDescription("List the Envoy configuration for a resource in the cluster"), + mcp.WithString("resource_name", mcp.Description("The name of the resource to get the Envoy configuration for"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to get the Envoy configuration for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_envoy_config", handleListEnvoyConfig))) + + s.AddTool(mcp.NewTool("cilium_fqdn_cache", + mcp.WithDescription("Manage the FQDN cache for the cluster"), + mcp.WithString("command", mcp.Description("The command to perform on the FQDN cache (list, clean, or a specific command)"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to manage the FQDN cache for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_fqdn_cache", handleFQDNCache))) + + s.AddTool(mcp.NewTool("cilium_show_dns_names", + mcp.WithDescription("Show the DNS names for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the DNS names for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_dns_names", handleShowDNSNames))) + + s.AddTool(mcp.NewTool("cilium_list_ip_addresses", + mcp.WithDescription("List the IP addresses for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the IP addresses for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_ip_addresses", handleListIPAddresses))) + + s.AddTool(mcp.NewTool("cilium_show_ip_cache_information", + mcp.WithDescription("Show the IP cache information for the cluster"), + mcp.WithString("cidr", mcp.Description("The CIDR of the IP to get cache information for")), + mcp.WithString("labels", mcp.Description("The labels of the IP to get cache information for")), + mcp.WithString("node_name", mcp.Description("The name of the node to get the IP cache information for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_ip_cache_information", handleShowIPCacheInformation))) + + // Write tool - delete_key_from_kv_store + if !readOnly { + s.AddTool(mcp.NewTool("cilium_delete_key_from_kv_store", + mcp.WithDescription("Delete a key from the kvstore for the cluster"), + mcp.WithString("key", mcp.Description("The key to delete from the kvstore"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to delete the key from")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_delete_key_from_kv_store", handleDeleteKeyFromKVStore))) + } + + s.AddTool(mcp.NewTool("cilium_get_kv_store_key", + mcp.WithDescription("Get a key from the kvstore for the cluster"), + mcp.WithString("key", mcp.Description("The key to get from the kvstore"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to get the key from")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_kv_store_key", handleGetKVStoreKey))) + + // Write tool - set_kv_store_key + if !readOnly { + s.AddTool(mcp.NewTool("cilium_set_kv_store_key", + mcp.WithDescription("Set a key in the kvstore for the cluster"), + mcp.WithString("key", mcp.Description("The key to set in the kvstore"), mcp.Required()), + mcp.WithString("value", mcp.Description("The value to set in the kvstore"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to set the key in")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_set_kv_store_key", handleSetKVStoreKey))) + } + + s.AddTool(mcp.NewTool("cilium_show_load_information", + mcp.WithDescription("Show load information for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the load information for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_show_load_information", handleShowLoadInformation))) + + s.AddTool(mcp.NewTool("cilium_list_local_redirect_policies", + mcp.WithDescription("List local redirect policies for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the local redirect policies for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_local_redirect_policies", handleListLocalRedirectPolicies))) + + s.AddTool(mcp.NewTool("cilium_list_bpf_map_events", + mcp.WithDescription("List BPF map events for the cluster"), + mcp.WithString("map_name", mcp.Description("The name of the BPF map to get events for"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to get the BPF map events for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_bpf_map_events", handleListBPFMapEvents))) + + s.AddTool(mcp.NewTool("cilium_get_bpf_map", + mcp.WithDescription("Get BPF map for the cluster"), + mcp.WithString("map_name", mcp.Description("The name of the BPF map to get"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to get the BPF map for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_bpf_map", handleGetBPFMap))) + + s.AddTool(mcp.NewTool("cilium_list_bpf_maps", + mcp.WithDescription("List BPF maps for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the BPF maps for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_bpf_maps", handleListBPFMaps))) + + s.AddTool(mcp.NewTool("cilium_list_metrics", + mcp.WithDescription("List metrics for the cluster"), + mcp.WithString("match_pattern", mcp.Description("The match pattern to filter metrics by")), + mcp.WithString("node_name", mcp.Description("The name of the node to get the metrics for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_metrics", handleListMetrics))) + + s.AddTool(mcp.NewTool("cilium_list_cluster_nodes", + mcp.WithDescription("List cluster nodes for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the cluster nodes for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_cluster_nodes", handleListClusterNodes))) + + s.AddTool(mcp.NewTool("cilium_list_node_ids", + mcp.WithDescription("List node IDs for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the node IDs for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_node_ids", handleListNodeIds))) + + s.AddTool(mcp.NewTool("cilium_display_policy_node_information", + mcp.WithDescription("Display policy node information for the cluster"), + mcp.WithString("labels", mcp.Description("The labels to get policy node information for")), + mcp.WithString("node_name", mcp.Description("The name of the node to get policy node information for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_display_policy_node_information", handleDisplayPolicyNodeInformation))) + + // Write tool - delete_policy_rules + if !readOnly { + s.AddTool(mcp.NewTool("cilium_delete_policy_rules", + mcp.WithDescription("Delete policy rules for the cluster"), + mcp.WithString("labels", mcp.Description("The labels to delete policy rules for")), + mcp.WithString("all", mcp.Description("Whether to delete all policy rules")), + mcp.WithString("node_name", mcp.Description("The name of the node to delete policy rules for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_delete_policy_rules", handleDeletePolicyRules))) } - if podName == "" { - return "", fmt.Errorf("no cilium pod found") + + s.AddTool(mcp.NewTool("cilium_display_selectors", + mcp.WithDescription("Display selectors for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get selectors for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_display_selectors", handleDisplaySelectors))) + + s.AddTool(mcp.NewTool("cilium_list_xdp_cidr_filters", + mcp.WithDescription("List XDP CIDR filters for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the XDP CIDR filters for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_xdp_cidr_filters", handleListXDPCIDRFilters))) + + // Write tools - XDP CIDR filters + if !readOnly { + s.AddTool(mcp.NewTool("cilium_update_xdp_cidr_filters", + mcp.WithDescription("Update XDP CIDR filters for the cluster"), + mcp.WithString("cidr_prefixes", mcp.Description("The CIDR prefixes to update the XDP filters for"), mcp.Required()), + mcp.WithString("revision", mcp.Description("The revision of the XDP filters to update")), + mcp.WithString("node_name", mcp.Description("The name of the node to update the XDP filters for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_update_xdp_cidr_filters", handleUpdateXDPCIDRFilters))) + + s.AddTool(mcp.NewTool("cilium_delete_xdp_cidr_filters", + mcp.WithDescription("Delete XDP CIDR filters for the cluster"), + mcp.WithString("cidr_prefixes", mcp.Description("The CIDR prefixes to delete the XDP filters for"), mcp.Required()), + mcp.WithString("revision", mcp.Description("The revision of the XDP filters to delete")), + mcp.WithString("node_name", mcp.Description("The name of the node to delete the XDP filters for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_delete_xdp_cidr_filters", handleDeleteXDPCIDRFilters))) + } + + s.AddTool(mcp.NewTool("cilium_validate_cilium_network_policies", + mcp.WithDescription("Validate Cilium network policies for the cluster"), + mcp.WithString("enable_k8s", mcp.Description("Whether to enable k8s API discovery")), + mcp.WithString("enable_k8s_api_discovery", mcp.Description("Whether to enable k8s API discovery")), + mcp.WithString("node_name", mcp.Description("The name of the node to validate the Cilium network policies for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_validate_cilium_network_policies", handleValidateCiliumNetworkPolicies))) + + s.AddTool(mcp.NewTool("cilium_list_pcap_recorders", + mcp.WithDescription("List PCAP recorders for the cluster"), + mcp.WithString("node_name", mcp.Description("The name of the node to get the PCAP recorders for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_list_pcap_recorders", handleListPCAPRecorders))) + + s.AddTool(mcp.NewTool("cilium_get_pcap_recorder", + mcp.WithDescription("Get a PCAP recorder for the cluster"), + mcp.WithString("recorder_id", mcp.Description("The ID of the PCAP recorder to get"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to get the PCAP recorder for")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_get_pcap_recorder", handleGetPCAPRecorder))) + + // Write tools - PCAP recorder management + if !readOnly { + s.AddTool(mcp.NewTool("cilium_delete_pcap_recorder", + mcp.WithDescription("Delete a PCAP recorder for the cluster"), + mcp.WithString("recorder_id", mcp.Description("The ID of the PCAP recorder to delete"), mcp.Required()), + mcp.WithString("node_name", mcp.Description("The name of the node to delete the PCAP recorder from")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_delete_pcap_recorder", handleDeletePCAPRecorder))) + + s.AddTool(mcp.NewTool("cilium_update_pcap_recorder", + mcp.WithDescription("Update a PCAP recorder for the cluster"), + mcp.WithString("recorder_id", mcp.Description("The ID of the PCAP recorder to update"), mcp.Required()), + mcp.WithString("filters", mcp.Description("The filters to update the PCAP recorder with"), mcp.Required()), + mcp.WithString("caplen", mcp.Description("The caplen to update the PCAP recorder with")), + mcp.WithString("id", mcp.Description("The id to update the PCAP recorder with")), + mcp.WithString("node_name", mcp.Description("The name of the node to update the PCAP recorder on")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("cilium_update_pcap_recorder", handleUpdatePCAPRecorder))) } - return strings.TrimSpace(podName), nil } -func runCiliumDbgCommand(command, nodeName string) (string, error) { - return runCiliumDbgCommandWithContext(context.Background(), command, nodeName) +// -- Debug Tools -- + +func getCiliumPodNameWithContext(ctx context.Context, nodeName string) (string, error) { + args := []string{"get", "pods", "-n", "kube-system", "--selector=k8s-app=cilium", fmt.Sprintf("--field-selector=spec.nodeName=%s", nodeName), "-o", "jsonpath={.items[0].metadata.name}"} + kubeconfigPath := utils.GetKubeconfig() + return commands.NewCommandBuilder("kubectl"). + WithArgs(args...). + WithKubeconfig(kubeconfigPath). + Execute(ctx) +} + +func runCiliumDbgCommand(ctx context.Context, command, nodeName string) (string, error) { + return runCiliumDbgCommandWithContext(ctx, command, nodeName) } func runCiliumDbgCommandWithContext(ctx context.Context, command, nodeName string) (string, error) { @@ -361,10 +620,13 @@ func runCiliumDbgCommandWithContext(ctx context.Context, command, nodeName strin if err != nil { return "", err } - cmdParts := strings.Fields(command) - args := []string{"exec", "-it", podName, "-n", "kube-system", "--", "cilium-dbg"} - args = append(args, cmdParts...) - return utils.RunCommandWithContext(ctx, "kubectl", args) + args := []string{"exec", "-n", "kube-system", podName, "--", "cilium-dbg"} + args = append(args, strings.Fields(command)...) + kubeconfigPath := utils.GetKubeconfig() + return commands.NewCommandBuilder("kubectl"). + WithArgs(args...). + WithKubeconfig(kubeconfigPath). + Execute(ctx) } func handleGetEndpointDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -382,7 +644,7 @@ func handleGetEndpointDetails(ctx context.Context, request mcp.CallToolRequest) return mcp.NewToolResultError("either endpoint_id or labels must be provided"), nil } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint details: %v", err)), nil } @@ -398,7 +660,7 @@ func handleGetEndpointLogs(ctx context.Context, request mcp.CallToolRequest) (*m } cmd := fmt.Sprintf("endpoint logs %s", endpointID) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint logs: %v", err)), nil } @@ -414,7 +676,7 @@ func handleGetEndpointHealth(ctx context.Context, request mcp.CallToolRequest) ( } cmd := fmt.Sprintf("endpoint health %s", endpointID) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoint health: %v", err)), nil } @@ -432,7 +694,7 @@ func handleManageEndpointLabels(ctx context.Context, request mcp.CallToolRequest } cmd := fmt.Sprintf("endpoint labels %s --%s %s", endpointID, action, labels) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to manage endpoint labels: %v", err)), nil } @@ -452,7 +714,7 @@ func handleManageEndpointConfiguration(ctx context.Context, request mcp.CallTool } command := fmt.Sprintf("endpoint config %s %s", endpointID, config) - output, err := runCiliumDbgCommand(command, nodeName) + output, err := runCiliumDbgCommand(ctx, command, nodeName) if err != nil { return mcp.NewToolResultError("Error managing endpoint configuration: " + err.Error()), nil } @@ -469,7 +731,7 @@ func handleDisconnectEndpoint(ctx context.Context, request mcp.CallToolRequest) } cmd := fmt.Sprintf("endpoint disconnect %s", endpointID) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to disconnect endpoint: %v", err)), nil } @@ -479,7 +741,7 @@ func handleDisconnectEndpoint(ctx context.Context, request mcp.CallToolRequest) func handleGetEndpointsList(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("endpoint list", nodeName) + output, err := runCiliumDbgCommand(ctx, "endpoint list", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get endpoints list: %v", err)), nil } @@ -489,7 +751,7 @@ func handleGetEndpointsList(ctx context.Context, request mcp.CallToolRequest) (* func handleListIdentities(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("identity list", nodeName) + output, err := runCiliumDbgCommand(ctx, "identity list", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list identities: %v", err)), nil } @@ -505,7 +767,7 @@ func handleGetIdentityDetails(ctx context.Context, request mcp.CallToolRequest) } cmd := fmt.Sprintf("identity get %s", identityID) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get identity details: %v", err)), nil } @@ -520,16 +782,16 @@ func handleShowConfigurationOptions(ctx context.Context, request mcp.CallToolReq var cmd string if listAll { - cmd = "endpoint config --all" + cmd = "config --all" } else if listReadOnly { - cmd = "endpoint config -r" + cmd = "config -r" } else if listOptions { - cmd = "endpoint config --list-options" + cmd = "config --list-options" } else { - cmd = "endpoint config" + cmd = "config" } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to show configuration options: %v", err)), nil } @@ -550,8 +812,8 @@ func handleToggleConfigurationOption(ctx context.Context, request mcp.CallToolRe valueStr = "disable" } - cmd := fmt.Sprintf("endpoint config %s=%s", option, valueStr) - output, err := runCiliumDbgCommand(cmd, nodeName) + cmd := fmt.Sprintf("config %s=%s", option, valueStr) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to toggle configuration option: %v", err)), nil } @@ -561,7 +823,7 @@ func handleToggleConfigurationOption(ctx context.Context, request mcp.CallToolRe func handleRequestDebuggingInformation(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("debuginfo", nodeName) + output, err := runCiliumDbgCommand(ctx, "debuginfo", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to request debugging information: %v", err)), nil } @@ -571,7 +833,7 @@ func handleRequestDebuggingInformation(ctx context.Context, request mcp.CallTool func handleDisplayEncryptionState(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("encrypt status", nodeName) + output, err := runCiliumDbgCommand(ctx, "encrypt status", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to display encryption state: %v", err)), nil } @@ -581,7 +843,7 @@ func handleDisplayEncryptionState(ctx context.Context, request mcp.CallToolReque func handleFlushIPsecState(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("encrypt flush -f", nodeName) + output, err := runCiliumDbgCommand(ctx, "encrypt flush -f", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to flush IPsec state: %v", err)), nil } @@ -597,7 +859,7 @@ func handleListEnvoyConfig(ctx context.Context, request mcp.CallToolRequest) (*m } cmd := fmt.Sprintf("envoy admin %s", resourceName) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list Envoy config: %v", err)), nil } @@ -610,12 +872,12 @@ func handleFQDNCache(ctx context.Context, request mcp.CallToolRequest) (*mcp.Cal var cmd string if command == "clean" { - cmd = "fqdn cache clean -f" + cmd = "fqdn cache clean" } else { - cmd = fmt.Sprintf("fqdn cache %s", command) + cmd = "fqdn cache list" } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to manage FQDN cache: %v", err)), nil } @@ -625,7 +887,7 @@ func handleFQDNCache(ctx context.Context, request mcp.CallToolRequest) (*mcp.Cal func handleShowDNSNames(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("dns names", nodeName) + output, err := runCiliumDbgCommand(ctx, "fqdn names", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to show DNS names: %v", err)), nil } @@ -635,7 +897,7 @@ func handleShowDNSNames(ctx context.Context, request mcp.CallToolRequest) (*mcp. func handleListIPAddresses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("ip list", nodeName) + output, err := runCiliumDbgCommand(ctx, "ip list", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list IP addresses: %v", err)), nil } @@ -656,7 +918,7 @@ func handleShowIPCacheInformation(ctx context.Context, request mcp.CallToolReque return mcp.NewToolResultError("either cidr or labels must be provided"), nil } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to show IP cache information: %v", err)), nil } @@ -672,7 +934,7 @@ func handleDeleteKeyFromKVStore(ctx context.Context, request mcp.CallToolRequest } cmd := fmt.Sprintf("kvstore delete %s", key) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to delete key from kvstore: %v", err)), nil } @@ -688,7 +950,7 @@ func handleGetKVStoreKey(ctx context.Context, request mcp.CallToolRequest) (*mcp } cmd := fmt.Sprintf("kvstore get %s", key) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get key from kvstore: %v", err)), nil } @@ -705,7 +967,7 @@ func handleSetKVStoreKey(ctx context.Context, request mcp.CallToolRequest) (*mcp } cmd := fmt.Sprintf("kvstore set %s=%s", key, value) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to set key in kvstore: %v", err)), nil } @@ -715,7 +977,7 @@ func handleSetKVStoreKey(ctx context.Context, request mcp.CallToolRequest) (*mcp func handleShowLoadInformation(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("loadinfo", nodeName) + output, err := runCiliumDbgCommand(ctx, "loadinfo", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to show load information: %v", err)), nil } @@ -725,7 +987,7 @@ func handleShowLoadInformation(ctx context.Context, request mcp.CallToolRequest) func handleListLocalRedirectPolicies(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("lrp list", nodeName) + output, err := runCiliumDbgCommand(ctx, "lrp list", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list local redirect policies: %v", err)), nil } @@ -740,8 +1002,8 @@ func handleListBPFMapEvents(ctx context.Context, request mcp.CallToolRequest) (* return mcp.NewToolResultError("map_name parameter is required"), nil } - cmd := fmt.Sprintf("bpf map events %s", mapName) - output, err := runCiliumDbgCommand(cmd, nodeName) + cmd := fmt.Sprintf("map events %s", mapName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list BPF map events: %v", err)), nil } @@ -756,8 +1018,8 @@ func handleGetBPFMap(ctx context.Context, request mcp.CallToolRequest) (*mcp.Cal return mcp.NewToolResultError("map_name parameter is required"), nil } - cmd := fmt.Sprintf("bpf map get %s", mapName) - output, err := runCiliumDbgCommand(cmd, nodeName) + cmd := fmt.Sprintf("map get %s", mapName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get BPF map: %v", err)), nil } @@ -767,7 +1029,7 @@ func handleGetBPFMap(ctx context.Context, request mcp.CallToolRequest) (*mcp.Cal func handleListBPFMaps(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("bpf map list", nodeName) + output, err := runCiliumDbgCommand(ctx, "map list", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list BPF maps: %v", err)), nil } @@ -785,7 +1047,7 @@ func handleListMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.C cmd = "metrics list" } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list metrics: %v", err)), nil } @@ -795,7 +1057,7 @@ func handleListMetrics(ctx context.Context, request mcp.CallToolRequest) (*mcp.C func handleListClusterNodes(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("nodes list", nodeName) + output, err := runCiliumDbgCommand(ctx, "node list", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list cluster nodes: %v", err)), nil } @@ -805,7 +1067,7 @@ func handleListClusterNodes(ctx context.Context, request mcp.CallToolRequest) (* func handleListNodeIds(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("nodeid list", nodeName) + output, err := runCiliumDbgCommand(ctx, "nodeid list", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list node IDs: %v", err)), nil } @@ -823,7 +1085,7 @@ func handleDisplayPolicyNodeInformation(ctx context.Context, request mcp.CallToo cmd = "policy get" } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to display policy node information: %v", err)), nil } @@ -844,7 +1106,7 @@ func handleDeletePolicyRules(ctx context.Context, request mcp.CallToolRequest) ( return mcp.NewToolResultError("either labels or all=true must be provided"), nil } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to delete policy rules: %v", err)), nil } @@ -854,7 +1116,7 @@ func handleDeletePolicyRules(ctx context.Context, request mcp.CallToolRequest) ( func handleDisplaySelectors(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("policy selectors", nodeName) + output, err := runCiliumDbgCommand(ctx, "policy selectors", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to display selectors: %v", err)), nil } @@ -864,7 +1126,7 @@ func handleDisplaySelectors(ctx context.Context, request mcp.CallToolRequest) (* func handleListXDPCIDRFilters(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("prefilter list", nodeName) + output, err := runCiliumDbgCommand(ctx, "prefilter list", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list XDP CIDR filters: %v", err)), nil } @@ -887,7 +1149,7 @@ func handleUpdateXDPCIDRFilters(ctx context.Context, request mcp.CallToolRequest cmd = fmt.Sprintf("prefilter update --cidr %s", cidrPrefixes) } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to update XDP CIDR filters: %v", err)), nil } @@ -910,7 +1172,7 @@ func handleDeleteXDPCIDRFilters(ctx context.Context, request mcp.CallToolRequest cmd = fmt.Sprintf("prefilter delete --cidr %s", cidrPrefixes) } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to delete XDP CIDR filters: %v", err)), nil } @@ -930,7 +1192,7 @@ func handleValidateCiliumNetworkPolicies(ctx context.Context, request mcp.CallTo cmd += " --enable-k8s-api-discovery" } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to validate Cilium network policies: %v", err)), nil } @@ -940,7 +1202,7 @@ func handleValidateCiliumNetworkPolicies(ctx context.Context, request mcp.CallTo func handleListPCAPRecorders(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { nodeName := mcp.ParseString(request, "node_name", "") - output, err := runCiliumDbgCommand("recorder list", nodeName) + output, err := runCiliumDbgCommand(ctx, "recorder list", nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list PCAP recorders: %v", err)), nil } @@ -956,7 +1218,7 @@ func handleGetPCAPRecorder(ctx context.Context, request mcp.CallToolRequest) (*m } cmd := fmt.Sprintf("recorder get %s", recorderID) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get PCAP recorder: %v", err)), nil } @@ -972,7 +1234,7 @@ func handleDeletePCAPRecorder(ctx context.Context, request mcp.CallToolRequest) } cmd := fmt.Sprintf("recorder delete %s", recorderID) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to delete PCAP recorder: %v", err)), nil } @@ -991,7 +1253,7 @@ func handleUpdatePCAPRecorder(ctx context.Context, request mcp.CallToolRequest) } cmd := fmt.Sprintf("recorder update %s --filters %s --caplen %s --id %s", recorderID, filters, caplen, id) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to update PCAP recorder: %v", err)), nil } @@ -1009,7 +1271,7 @@ func handleListServices(ctx context.Context, request mcp.CallToolRequest) (*mcp. cmd = "service list" } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list services: %v", err)), nil } @@ -1025,7 +1287,7 @@ func handleGetServiceInformation(ctx context.Context, request mcp.CallToolReques } cmd := fmt.Sprintf("service get %s", serviceID) - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get service information: %v", err)), nil } @@ -1046,7 +1308,7 @@ func handleDeleteService(ctx context.Context, request mcp.CallToolRequest) (*mcp return mcp.NewToolResultError("either service_id or all=true must be provided"), nil } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to delete service: %v", err)), nil } @@ -1105,7 +1367,7 @@ func handleUpdateService(ctx context.Context, request mcp.CallToolRequest) (*mcp cmd += " --local-redirect" } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to update service: %v", err)), nil } @@ -1145,239 +1407,9 @@ func handleGetDaemonStatus(ctx context.Context, request mcp.CallToolRequest) (*m cmd += " --brief" } - output, err := runCiliumDbgCommand(cmd, nodeName) + output, err := runCiliumDbgCommand(ctx, cmd, nodeName) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get daemon status: %v", err)), nil } return mcp.NewToolResultText(output), nil } - -func RegisterCiliumDbgTools(s *server.MCPServer) { - s.AddTool(mcp.NewTool("cilium_get_endpoint_details", - mcp.WithDescription("List the details of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to get details for")), - mcp.WithString("labels", mcp.Description("The labels of the endpoint to get details for")), - mcp.WithString("output_format", mcp.Description("The output format of the endpoint details (json, yaml, jsonpath)")), - mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint details for")), - ), handleGetEndpointDetails) - - s.AddTool(mcp.NewTool("cilium_get_endpoint_logs", - mcp.WithDescription("Get the logs of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to get logs for"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint logs for")), - ), handleGetEndpointLogs) - - s.AddTool(mcp.NewTool("cilium_get_endpoint_health", - mcp.WithDescription("Get the health of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to get health for"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the endpoint health for")), - ), handleGetEndpointHealth) - - s.AddTool(mcp.NewTool("cilium_manage_endpoint_labels", - mcp.WithDescription("Manage the labels (add or delete) of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to manage labels for"), mcp.Required()), - mcp.WithString("labels", mcp.Description("Space-separated labels to manage (e.g., 'key1=value1 key2=value2')"), mcp.Required()), - mcp.WithString("action", mcp.Description("The action to perform on the labels (add or delete)"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to manage the endpoint labels on")), - ), handleManageEndpointLabels) - - s.AddTool(mcp.NewTool("cilium_manage_endpoint_config", - mcp.WithDescription("Manage the configuration of an endpoint in the cluster"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to manage configuration for"), mcp.Required()), - mcp.WithString("config", mcp.Description("The configuration to manage for the endpoint provided as a space-separated list of key-value pairs (e.g. 'DropNotification=false TraceNotification=false')"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to manage the endpoint configuration on")), - ), handleManageEndpointConfiguration) - - s.AddTool(mcp.NewTool("cilium_disconnect_endpoint", - mcp.WithDescription("Disconnect an endpoint from the network"), - mcp.WithString("endpoint_id", mcp.Description("The ID of the endpoint to disconnect"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to disconnect the endpoint from")), - ), handleDisconnectEndpoint) - - s.AddTool(mcp.NewTool("cilium_list_identities", - mcp.WithDescription("List all identities in the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to list the identities for")), - ), handleListIdentities) - - s.AddTool(mcp.NewTool("cilium_get_identity_details", - mcp.WithDescription("Get the details of an identity in the cluster"), - mcp.WithString("identity_id", mcp.Description("The ID of the identity to get details for"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the identity details for")), - ), handleGetIdentityDetails) - - s.AddTool(mcp.NewTool("cilium_request_debugging_information", - mcp.WithDescription("Request debugging information for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the debugging information for")), - ), handleRequestDebuggingInformation) - - s.AddTool(mcp.NewTool("cilium_display_encryption_state", - mcp.WithDescription("Display the encryption state for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the encryption state for")), - ), handleDisplayEncryptionState) - - s.AddTool(mcp.NewTool("cilium_flush_ipsec_state", - mcp.WithDescription("Flush the IPsec state for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to flush the IPsec state for")), - ), handleFlushIPsecState) - - s.AddTool(mcp.NewTool("cilium_list_envoy_config", - mcp.WithDescription("List the Envoy configuration for a resource in the cluster"), - mcp.WithString("resource_name", mcp.Description("The name of the resource to get the Envoy configuration for"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the Envoy configuration for")), - ), handleListEnvoyConfig) - - s.AddTool(mcp.NewTool("cilium_fqdn_cache", - mcp.WithDescription("Manage the FQDN cache for the cluster"), - mcp.WithString("command", mcp.Description("The command to perform on the FQDN cache (list, clean, or a specific command)"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to manage the FQDN cache for")), - ), handleFQDNCache) - - s.AddTool(mcp.NewTool("cilium_show_dns_names", - mcp.WithDescription("Show the DNS names for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the DNS names for")), - ), handleShowDNSNames) - - s.AddTool(mcp.NewTool("cilium_list_ip_addresses", - mcp.WithDescription("List the IP addresses for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the IP addresses for")), - ), handleListIPAddresses) - - s.AddTool(mcp.NewTool("cilium_show_ip_cache_information", - mcp.WithDescription("Show the IP cache information for the cluster"), - mcp.WithString("cidr", mcp.Description("The CIDR of the IP to get cache information for")), - mcp.WithString("labels", mcp.Description("The labels of the IP to get cache information for")), - mcp.WithString("node_name", mcp.Description("The name of the node to get the IP cache information for")), - ), handleShowIPCacheInformation) - - s.AddTool(mcp.NewTool("cilium_delete_key_from_kv_store", - mcp.WithDescription("Delete a key from the kvstore for the cluster"), - mcp.WithString("key", mcp.Description("The key to delete from the kvstore"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to delete the key from")), - ), handleDeleteKeyFromKVStore) - - s.AddTool(mcp.NewTool("cilium_get_kv_store_key", - mcp.WithDescription("Get a key from the kvstore for the cluster"), - mcp.WithString("key", mcp.Description("The key to get from the kvstore"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the key from")), - ), handleGetKVStoreKey) - - s.AddTool(mcp.NewTool("cilium_set_kv_store_key", - mcp.WithDescription("Set a key in the kvstore for the cluster"), - mcp.WithString("key", mcp.Description("The key to set in the kvstore"), mcp.Required()), - mcp.WithString("value", mcp.Description("The value to set in the kvstore"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to set the key in")), - ), handleSetKVStoreKey) - - s.AddTool(mcp.NewTool("cilium_show_load_information", - mcp.WithDescription("Show load information for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the load information for")), - ), handleShowLoadInformation) - - s.AddTool(mcp.NewTool("cilium_list_local_redirect_policies", - mcp.WithDescription("List local redirect policies for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the local redirect policies for")), - ), handleListLocalRedirectPolicies) - - s.AddTool(mcp.NewTool("cilium_list_bpf_map_events", - mcp.WithDescription("List BPF map events for the cluster"), - mcp.WithString("map_name", mcp.Description("The name of the BPF map to get events for"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the BPF map events for")), - ), handleListBPFMapEvents) - - s.AddTool(mcp.NewTool("cilium_get_bpf_map", - mcp.WithDescription("Get BPF map for the cluster"), - mcp.WithString("map_name", mcp.Description("The name of the BPF map to get"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the BPF map for")), - ), handleGetBPFMap) - - s.AddTool(mcp.NewTool("cilium_list_bpf_maps", - mcp.WithDescription("List BPF maps for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the BPF maps for")), - ), handleListBPFMaps) - - s.AddTool(mcp.NewTool("cilium_list_metrics", - mcp.WithDescription("List metrics for the cluster"), - mcp.WithString("match_pattern", mcp.Description("The match pattern to filter metrics by")), - mcp.WithString("node_name", mcp.Description("The name of the node to get the metrics for")), - ), handleListMetrics) - - s.AddTool(mcp.NewTool("cilium_list_cluster_nodes", - mcp.WithDescription("List cluster nodes for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the cluster nodes for")), - ), handleListClusterNodes) - - s.AddTool(mcp.NewTool("cilium_list_node_ids", - mcp.WithDescription("List node IDs for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the node IDs for")), - ), handleListNodeIds) - - s.AddTool(mcp.NewTool("cilium_display_policy_node_information", - mcp.WithDescription("Display policy node information for the cluster"), - mcp.WithString("labels", mcp.Description("The labels to get policy node information for")), - mcp.WithString("node_name", mcp.Description("The name of the node to get policy node information for")), - ), handleDisplayPolicyNodeInformation) - - s.AddTool(mcp.NewTool("cilium_delete_policy_rules", - mcp.WithDescription("Delete policy rules for the cluster"), - mcp.WithString("labels", mcp.Description("The labels to delete policy rules for")), - mcp.WithString("all", mcp.Description("Whether to delete all policy rules")), - mcp.WithString("node_name", mcp.Description("The name of the node to delete policy rules for")), - ), handleDeletePolicyRules) - - s.AddTool(mcp.NewTool("cilium_display_selectors", - mcp.WithDescription("Display selectors for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get selectors for")), - ), handleDisplaySelectors) - - s.AddTool(mcp.NewTool("cilium_list_xdp_cidr_filters", - mcp.WithDescription("List XDP CIDR filters for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the XDP CIDR filters for")), - ), handleListXDPCIDRFilters) - - s.AddTool(mcp.NewTool("cilium_update_xdp_cidr_filters", - mcp.WithDescription("Update XDP CIDR filters for the cluster"), - mcp.WithString("cidr_prefixes", mcp.Description("The CIDR prefixes to update the XDP filters for"), mcp.Required()), - mcp.WithString("revision", mcp.Description("The revision of the XDP filters to update")), - mcp.WithString("node_name", mcp.Description("The name of the node to update the XDP filters for")), - ), handleUpdateXDPCIDRFilters) - - s.AddTool(mcp.NewTool("cilium_delete_xdp_cidr_filters", - mcp.WithDescription("Delete XDP CIDR filters for the cluster"), - mcp.WithString("cidr_prefixes", mcp.Description("The CIDR prefixes to delete the XDP filters for"), mcp.Required()), - mcp.WithString("revision", mcp.Description("The revision of the XDP filters to delete")), - mcp.WithString("node_name", mcp.Description("The name of the node to delete the XDP filters for")), - ), handleDeleteXDPCIDRFilters) - - s.AddTool(mcp.NewTool("cilium_validate_cilium_network_policies", - mcp.WithDescription("Validate Cilium network policies for the cluster"), - mcp.WithString("enable_k8s", mcp.Description("Whether to enable k8s API discovery")), - mcp.WithString("enable_k8s_api_discovery", mcp.Description("Whether to enable k8s API discovery")), - mcp.WithString("node_name", mcp.Description("The name of the node to validate the Cilium network policies for")), - ), handleValidateCiliumNetworkPolicies) - - s.AddTool(mcp.NewTool("cilium_list_pcap_recorders", - mcp.WithDescription("List PCAP recorders for the cluster"), - mcp.WithString("node_name", mcp.Description("The name of the node to get the PCAP recorders for")), - ), handleListPCAPRecorders) - - s.AddTool(mcp.NewTool("cilium_get_pcap_recorder", - mcp.WithDescription("Get a PCAP recorder for the cluster"), - mcp.WithString("recorder_id", mcp.Description("The ID of the PCAP recorder to get"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to get the PCAP recorder for")), - ), handleGetPCAPRecorder) - - s.AddTool(mcp.NewTool("cilium_delete_pcap_recorder", - mcp.WithDescription("Delete a PCAP recorder for the cluster"), - mcp.WithString("recorder_id", mcp.Description("The ID of the PCAP recorder to delete"), mcp.Required()), - mcp.WithString("node_name", mcp.Description("The name of the node to delete the PCAP recorder from")), - ), handleDeletePCAPRecorder) - - s.AddTool(mcp.NewTool("cilium_update_pcap_recorder", - mcp.WithDescription("Update a PCAP recorder for the cluster"), - mcp.WithString("recorder_id", mcp.Description("The ID of the PCAP recorder to update"), mcp.Required()), - mcp.WithString("filters", mcp.Description("The filters to update the PCAP recorder with"), mcp.Required()), - mcp.WithString("caplen", mcp.Description("The caplen to update the PCAP recorder with")), - mcp.WithString("id", mcp.Description("The id to update the PCAP recorder with")), - mcp.WithString("node_name", mcp.Description("The name of the node to update the PCAP recorder on")), - ), handleUpdatePCAPRecorder) -} diff --git a/pkg/cilium/cilium_test.go b/pkg/cilium/cilium_test.go index 22a90881..50bbed6d 100644 --- a/pkg/cilium/cilium_test.go +++ b/pkg/cilium/cilium_test.go @@ -1,281 +1,621 @@ package cilium import ( + "context" + "errors" + "fmt" "strings" "testing" + + "github.com/kagent-dev/tools/internal/cmd" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestCiliumStatusAndVersion(t *testing.T) { - // Test cilium status argument construction - testCases := []struct { - namespace string - verbose bool - wait bool - expectedArgs []string - }{ - { - expectedArgs: []string{"status"}, - }, - { - namespace: "kube-system", - expectedArgs: []string{"status", "-n", "kube-system"}, - }, - { - verbose: true, - expectedArgs: []string{"status", "-v"}, - }, - { - wait: true, - expectedArgs: []string{"status", "--wait"}, - }, - { - namespace: "cilium-system", - verbose: true, - wait: true, - expectedArgs: []string{"status", "-n", "cilium-system", "-v", "--wait"}, - }, - } +func TestRegisterCiliumTools(t *testing.T) { + s := server.NewMCPServer("test-server", "v0.0.1") + RegisterTools(s, false) // false = enable all tools including write operations + // We can't directly check the tools, but we can ensure the call doesn't panic +} + +func TestHandleCiliumStatusAndVersion(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"status"}, "Cilium status: OK", nil) + mock.AddCommandString("cilium", []string{"version"}, "cilium version 1.14.0", nil) - for i, tc := range testCases { - args := []string{"status"} + ctx = cmd.WithShellExecutor(ctx, mock) - if tc.namespace != "" { - args = append(args, "-n", tc.namespace) + result, err := handleCiliumStatusAndVersion(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + var textContent mcp.TextContent + var ok bool + for _, content := range result.Content { + if textContent, ok = content.(mcp.TextContent); ok { + break } + } + require.True(t, ok, "no text content in result") + + assert.Contains(t, textContent.Text, "Cilium status: OK") + assert.Contains(t, textContent.Text, "cilium version 1.14.0") +} + +func TestHandleCiliumStatusAndVersionError(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"status"}, "", errors.New("command failed")) + mock.AddCommandString("cilium", []string{"version"}, "cilium version 1.14.0", nil) + + ctx = cmd.WithShellExecutor(ctx, mock) + + result, err := handleCiliumStatusAndVersion(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "Error getting Cilium status") +} - if tc.verbose { - args = append(args, "-v") +func TestHandleInstallCilium(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"install"}, "✓ Cilium was successfully installed!", nil) + + ctx = cmd.WithShellExecutor(ctx, mock) + + result, err := handleInstallCilium(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "✓ Cilium was successfully installed!") +} + +func TestHandleUninstallCilium(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"uninstall"}, "✓ Cilium was successfully uninstalled!", nil) + + ctx = cmd.WithShellExecutor(ctx, mock) + + result, err := handleUninstallCilium(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "✓ Cilium was successfully uninstalled!") +} + +func TestHandleUpgradeCilium(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"upgrade"}, "✓ Cilium was successfully upgraded!", nil) + + ctx = cmd.WithShellExecutor(ctx, mock) + + result, err := handleUpgradeCilium(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "✓ Cilium was successfully upgraded!") +} + +func TestHandleConnectToRemoteCluster(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"clustermesh", "connect", "--destination-cluster", "my-cluster"}, "✓ Connected to cluster my-cluster!", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "cluster_name": "my-cluster", + }, + }, } - if tc.wait { - args = append(args, "--wait") + result, err := handleConnectToRemoteCluster(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "✓ Connected to cluster my-cluster!") + }) + + t.Run("missing cluster_name", func(t *testing.T) { + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{}, + }, } + result, err := handleConnectToRemoteCluster(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "cluster_name parameter is required") + }) +} - if len(args) != len(tc.expectedArgs) { - t.Errorf("Test case %d: expected %d args, got %d", i, len(tc.expectedArgs), len(args)) - continue +func TestHandleDisconnectFromRemoteCluster(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"clustermesh", "disconnect", "--destination-cluster", "my-cluster"}, "✓ Disconnected from cluster my-cluster!", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "cluster_name": "my-cluster", + }, + }, } - for j, arg := range args { - if arg != tc.expectedArgs[j] { - t.Errorf("Test case %d: expected arg %d to be '%s', got '%s'", i, j, tc.expectedArgs[j], arg) - } + result, err := handleDisconnectRemoteCluster(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "✓ Disconnected from cluster my-cluster!") + }) + + t.Run("missing cluster_name", func(t *testing.T) { + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{}, + }, } - } + result, err := handleDisconnectRemoteCluster(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "cluster_name parameter is required") + }) } -func TestCiliumConnectivity(t *testing.T) { - // Test cilium connectivity argument construction - testCases := []struct { - action string - namespace string - test string - expectedLength int - }{ - { - action: "test", - expectedLength: 2, // ["connectivity", "test"] - }, - { - action: "test", - namespace: "default", - expectedLength: 4, // ["connectivity", "test", "-n", "default"] +func TestHandleEnableHubble(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"hubble", "enable"}, "✓ Hubble was successfully enabled!", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "enable": true, + }, }, - { - action: "test", - test: "pod-to-pod", - expectedLength: 4, // ["connectivity", "test", "--test", "pod-to-pod"] + } + + result, err := handleToggleHubble(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "✓ Hubble was successfully enabled!") +} + +func TestHandleDisableHubble(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"hubble", "disable"}, "✓ Hubble was successfully disabled!", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]any{ + "enable": false, + }, }, } + result, err := handleToggleHubble(ctx, req) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "✓ Hubble was successfully disabled!") +} - for i, tc := range testCases { - args := []string{"connectivity", tc.action} +func TestHandleListBGPPeers(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"bgp", "peers"}, "listing BGP peers", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + result, err := handleListBGPPeers(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "listing BGP peers") +} - if tc.namespace != "" { - args = append(args, "-n", tc.namespace) - } +func TestHandleListBGPRoutes(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"bgp", "routes"}, "listing BGP routes", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + result, err := handleListBGPRoutes(ctx, mcp.CallToolRequest{}) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "listing BGP routes") +} - if tc.test != "" { - args = append(args, "--test", tc.test) - } +func TestRunCiliumCliWithContext(t *testing.T) { + ctx := context.Background() + t.Run("success", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"test"}, "success", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + result, err := runCiliumCliWithContext(ctx, "test") + require.NoError(t, err) + assert.Equal(t, "success", result) + }) + t.Run("error", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("cilium", []string{"test"}, "", fmt.Errorf("test error")) + ctx = cmd.WithShellExecutor(ctx, mock) + _, err := runCiliumCliWithContext(ctx, "test") + require.Error(t, err) + assert.Contains(t, err.Error(), "test error") + }) +} - if len(args) != tc.expectedLength { - t.Errorf("Test case %d: expected %d args, got %d. Args: %v", i, tc.expectedLength, len(args), args) - } - } +// mockCiliumDbgCommand sets up the mock for a cilium-dbg command executed via kubectl exec. +// It mocks: (1) kubectl get pods to resolve the cilium pod name, (2) kubectl exec to run cilium-dbg. +func mockCiliumDbgCommand(mock *cmd.MockShellExecutor, dbgArgs []string, output string, err error) { + // Mock the pod name lookup + mock.AddCommandString("kubectl", []string{ + "get", "pods", "-n", "kube-system", + "--selector=k8s-app=cilium", + "--field-selector=spec.nodeName=test-node", + "-o", "jsonpath={.items[0].metadata.name}", + }, "cilium-abc123", nil) + + // Mock the kubectl exec call + execArgs := []string{"exec", "-n", "kube-system", "cilium-abc123", "--", "cilium-dbg"} + execArgs = append(execArgs, dbgArgs...) + mock.AddCommandString("kubectl", execArgs, output, err) } -func TestCiliumEndpoint(t *testing.T) { - // Test cilium endpoint argument construction - testCases := []struct { - action string - endpointID string - namespace string - expectedLength int - }{ - { - action: "list", - expectedLength: 2, // ["endpoint", "list"] - }, - { - action: "get", - endpointID: "1234", - expectedLength: 3, // ["endpoint", "get", "1234"] - }, - { - action: "list", - namespace: "default", - expectedLength: 4, // ["endpoint", "list", "-n", "default"] +func newRequestWithArgs(args map[string]any) mcp.CallToolRequest { + return mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: args, }, } +} - for i, tc := range testCases { - args := []string{"endpoint", tc.action} +func TestHandleGetEndpointsList(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"endpoint", "list"}, "ENDPOINT POLICY\n34 Disabled", nil) + ctx = cmd.WithShellExecutor(ctx, mock) - if tc.endpointID != "" { - args = append(args, tc.endpointID) - } + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleGetEndpointsList(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "ENDPOINT") +} - if tc.namespace != "" { - args = append(args, "-n", tc.namespace) - } +func TestHandleGetEndpointDetails(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"endpoint", "get", "34", "-o", "json"}, `{"id": 34}`, nil) + ctx = cmd.WithShellExecutor(ctx, mock) - if len(args) != tc.expectedLength { - t.Errorf("Test case %d: expected %d args, got %d. Args: %v", i, tc.expectedLength, len(args), args) - } - } + req := newRequestWithArgs(map[string]any{"endpoint_id": "34", "node_name": "test-node"}) + result, err := handleGetEndpointDetails(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), `"id": 34`) } -func TestCiliumPolicy(t *testing.T) { - // Test cilium policy argument construction - testCases := []struct { - action string - policyFile string - namespace string - expectedLength int - }{ - { - action: "get", - expectedLength: 2, // ["policy", "get"] - }, - { - action: "import", - policyFile: "policy.yaml", - expectedLength: 3, // ["policy", "import", "policy.yaml"] - }, - { - action: "get", - namespace: "default", - expectedLength: 4, // ["policy", "get", "-n", "default"] - }, - } +func TestHandleGetEndpointLogs(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"endpoint", "logs", "34"}, "endpoint log output", nil) + ctx = cmd.WithShellExecutor(ctx, mock) - for i, tc := range testCases { - args := []string{"policy", tc.action} + req := newRequestWithArgs(map[string]any{"endpoint_id": "34", "node_name": "test-node"}) + result, err := handleGetEndpointLogs(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "endpoint log output") +} - if tc.policyFile != "" { - args = append(args, tc.policyFile) - } +func TestHandleGetEndpointHealth(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"endpoint", "health", "34"}, "endpoint health OK", nil) + ctx = cmd.WithShellExecutor(ctx, mock) - if tc.namespace != "" { - args = append(args, "-n", tc.namespace) - } + req := newRequestWithArgs(map[string]any{"endpoint_id": "34", "node_name": "test-node"}) + result, err := handleGetEndpointHealth(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "endpoint health OK") +} - if len(args) != tc.expectedLength { - t.Errorf("Test case %d: expected %d args, got %d. Args: %v", i, tc.expectedLength, len(args), args) - } - } +func TestHandleShowConfigurationOptions(t *testing.T) { + t.Run("default", func(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"config"}, "PolicyEnforcement=default", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleShowConfigurationOptions(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "PolicyEnforcement") + }) + + t.Run("all", func(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"config", "--all"}, "all config options", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node", "list_all": "true"}) + result, err := handleShowConfigurationOptions(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "all config options") + }) + + t.Run("read_only", func(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"config", "-r"}, "read only config", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node", "list_read_only": "true"}) + result, err := handleShowConfigurationOptions(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "read only config") + }) } -func TestCiliumNode(t *testing.T) { - // Test cilium node argument construction - testCases := []struct { - action string - nodeName string - expectedLength int - }{ - { - action: "list", - expectedLength: 2, // ["node", "list"] - }, - { - action: "get", - nodeName: "node-1", - expectedLength: 3, // ["node", "get", "node-1"] - }, - } +func TestHandleToggleConfigurationOption(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"config", "PolicyEnforcement=enable"}, "option toggled", nil) + ctx = cmd.WithShellExecutor(ctx, mock) - for i, tc := range testCases { - args := []string{"node", tc.action} + req := newRequestWithArgs(map[string]any{"option": "PolicyEnforcement", "value": "true", "node_name": "test-node"}) + result, err := handleToggleConfigurationOption(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "option toggled") +} - if tc.nodeName != "" { - args = append(args, tc.nodeName) - } +func TestHandleListIdentities(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"identity", "list"}, "ID LABELS\n1 reserved:host", nil) + ctx = cmd.WithShellExecutor(ctx, mock) - if len(args) != tc.expectedLength { - t.Errorf("Test case %d: expected %d args, got %d. Args: %v", i, tc.expectedLength, len(args), args) - } - } + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleListIdentities(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "reserved:host") } -func TestCiliumDbgCommandConstruction(t *testing.T) { - // Test cilium-dbg command construction - testCases := []struct { - command string - nodeName string - expected string - }{ - { - command: "endpoint list", - expected: "endpoint list", - }, - { - command: "identity get 1234", - expected: "identity get 1234", - }, - { - command: "service list", - expected: "service list", - }, - } +func TestHandleGetDaemonStatus(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"status"}, "KVStore: Ok\nKubernetes: Ok", nil) + ctx = cmd.WithShellExecutor(ctx, mock) - for _, tc := range testCases { - // This tests the command construction logic - cmdParts := strings.Fields(tc.command) - reconstructed := strings.Join(cmdParts, " ") - if reconstructed != tc.expected { - t.Errorf("Command reconstruction failed: expected '%s', got '%s'", tc.expected, reconstructed) - } - } + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleGetDaemonStatus(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "KVStore: Ok") } -func TestCiliumDbgParameterParsing(t *testing.T) { - // Test parameter parsing for cilium-dbg commands - testCases := []struct { - paramName string - paramValue string - expected string - }{ - { - paramName: "endpoint_id", - paramValue: "1234", - expected: "1234", - }, - { - paramName: "labels", - paramValue: "app=test", - expected: "app=test", - }, - { - paramName: "output_format", - paramValue: "json", - expected: "json", - }, - } +func TestHandleDisplayEncryptionState(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"encrypt", "status"}, "Encryption: Disabled", nil) + ctx = cmd.WithShellExecutor(ctx, mock) - for _, tc := range testCases { - if tc.paramValue != tc.expected { - t.Errorf("Parameter parsing failed for %s: expected '%s', got '%s'", tc.paramName, tc.expected, tc.paramValue) - } + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleDisplayEncryptionState(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "Encryption: Disabled") +} + +func TestHandleShowDNSNames(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"fqdn", "names"}, "DNS names output", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleShowDNSNames(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "DNS names output") +} + +func TestHandleFQDNCache(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"fqdn", "cache", "list"}, "FQDN cache entries", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleFQDNCache(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "FQDN cache entries") +} + +func TestHandleListClusterNodes(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"node", "list"}, "Name IPv4 Address\nnode1 10.0.0.1", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleListClusterNodes(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "node1") +} + +func TestHandleListNodeIds(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"nodeid", "list"}, "ID IP\n1 10.0.0.1", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleListNodeIds(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "10.0.0.1") +} + +func TestHandleListBPFMaps(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"map", "list"}, "Name Num entries\ncilium_lb4 22", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleListBPFMaps(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "cilium_lb4") +} + +func TestHandleGetBPFMap(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"map", "get", "cilium_lb4"}, "map contents", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"map_name": "cilium_lb4", "node_name": "test-node"}) + result, err := handleGetBPFMap(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "map contents") +} + +func TestHandleListBPFMapEvents(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"map", "events", "cilium_lb4"}, "map events", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"map_name": "cilium_lb4", "node_name": "test-node"}) + result, err := handleListBPFMapEvents(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "map events") +} + +func TestHandleListMetrics(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"metrics", "list"}, "Metric Value\ncilium_endpoint_count 4", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleListMetrics(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "cilium_endpoint_count") +} + +func TestHandleListServices(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"service", "list"}, "ID Frontend\n1 10.96.0.1:443", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleListServices(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "10.96.0.1") +} + +func TestHandleListIPAddresses(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"ip", "list"}, "IP Identity\n10.0.0.1 1", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleListIPAddresses(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "10.0.0.1") +} + +func TestHandleDisplaySelectors(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"policy", "selectors"}, "SELECTOR IDENTITIES", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleDisplaySelectors(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "SELECTOR") +} + +func TestHandleListLocalRedirectPolicies(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"lrp", "list"}, "No local redirect policies", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleListLocalRedirectPolicies(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "No local redirect policies") +} + +func TestHandleRequestDebuggingInformation(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"debuginfo"}, "debug info output", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleRequestDebuggingInformation(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "debug info output") +} + +func TestHandleListXDPCIDRFilters(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() + mockCiliumDbgCommand(mock, []string{"prefilter", "list"}, "CIDR filters", nil) + ctx = cmd.WithShellExecutor(ctx, mock) + + req := newRequestWithArgs(map[string]any{"node_name": "test-node"}) + result, err := handleListXDPCIDRFilters(ctx, req) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Contains(t, getResultText(result), "CIDR filters") +} + +func getResultText(r *mcp.CallToolResult) string { + if r == nil || len(r.Content) == 0 { + return "" + } + if textContent, ok := r.Content[0].(mcp.TextContent); ok { + return strings.TrimSpace(textContent.Text) } + return "" } diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index 3c767d96..c8a6b917 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -4,7 +4,12 @@ import ( "context" "fmt" "strings" + "time" + "github.com/kagent-dev/tools/internal/commands" + "github.com/kagent-dev/tools/internal/errors" + "github.com/kagent-dev/tools/internal/security" + "github.com/kagent-dev/tools/internal/telemetry" "github.com/kagent-dev/tools/pkg/utils" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -65,14 +70,52 @@ func handleHelmListReleases(ctx context.Context, request mcp.CallToolRequest) (* args = append(args, "-o", output) } - result, err := utils.RunCommandWithContext(ctx, "helm", args) + result, err := runHelmCommand(ctx, args) if err != nil { + // Check if it's a structured error + if toolErr, ok := err.(*errors.ToolError); ok { + // Add namespace context if provided + if namespace != "" { + toolErr = toolErr.WithContext("namespace", namespace) + } + return toolErr.ToMCPResult(), nil + } + // Fallback for non-structured errors return mcp.NewToolResultError(fmt.Sprintf("Helm list command failed: %v", err)), nil } return mcp.NewToolResultText(result), nil } +func runHelmCommand(ctx context.Context, args []string) (string, error) { + kubeconfigPath := utils.GetKubeconfig() + + // Add timeout for helm upgrade commands + cmdBuilder := commands.NewCommandBuilder("helm"). + WithArgs(args...). + WithKubeconfig(kubeconfigPath) + + // Only add timeout for upgrade commands + if len(args) > 0 && args[0] == "upgrade" { + cmdBuilder = cmdBuilder.WithTimeout(30 * time.Second) + } + + result, err := cmdBuilder.Execute(ctx) + + if err != nil { + if toolErr, ok := err.(*errors.ToolError); ok { + if len(args) > 0 { + toolErr = toolErr.WithContext("helm_operation", args[0]) + } + toolErr = toolErr.WithContext("helm_args", args) + return "", toolErr + } + return "", err + } + + return result, nil +} + // Helm get release func handleHelmGetRelease(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { name := mcp.ParseString(request, "name", "") @@ -89,7 +132,7 @@ func handleHelmGetRelease(ctx context.Context, request mcp.CallToolRequest) (*mc args := []string{"get", resource, name, "-n", namespace} - result, err := utils.RunCommandWithContext(ctx, "helm", args) + result, err := runHelmCommand(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Helm get command failed: %v", err)), nil } @@ -113,6 +156,25 @@ func handleHelmUpgradeRelease(ctx context.Context, request mcp.CallToolRequest) return mcp.NewToolResultError("name and chart parameters are required"), nil } + // Validate release name + if err := security.ValidateHelmReleaseName(name); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid release name: %v", err)), nil + } + + // Validate namespace if provided + if namespace != "" { + if err := security.ValidateNamespace(namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil + } + } + + // Validate values file path if provided + if values != "" { + if err := security.ValidateFilePath(values); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid values file path: %v", err)), nil + } + } + args := []string{"upgrade", name, chart} if namespace != "" { @@ -147,7 +209,7 @@ func handleHelmUpgradeRelease(ctx context.Context, request mcp.CallToolRequest) args = append(args, "--wait") } - result, err := utils.RunCommandWithContext(ctx, "helm", args) + result, err := runHelmCommand(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Helm upgrade command failed: %v", err)), nil } @@ -176,7 +238,7 @@ func handleHelmUninstall(ctx context.Context, request mcp.CallToolRequest) (*mcp args = append(args, "--wait") } - result, err := utils.RunCommandWithContext(ctx, "helm", args) + result, err := runHelmCommand(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Helm uninstall command failed: %v", err)), nil } @@ -193,9 +255,19 @@ func handleHelmRepoAdd(ctx context.Context, request mcp.CallToolRequest) (*mcp.C return mcp.NewToolResultError("name and url parameters are required"), nil } + // Validate repository name + if err := security.ValidateHelmReleaseName(name); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid repository name: %v", err)), nil + } + + // Validate repository URL + if err := security.ValidateURL(url); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid repository URL: %v", err)), nil + } + args := []string{"repo", "add", name, url} - result, err := utils.RunCommandWithContext(ctx, "helm", args) + result, err := runHelmCommand(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Helm repo add command failed: %v", err)), nil } @@ -207,7 +279,7 @@ func handleHelmRepoAdd(ctx context.Context, request mcp.CallToolRequest) (*mcp.C func handleHelmRepoUpdate(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := []string{"repo", "update"} - result, err := utils.RunCommandWithContext(ctx, "helm", args) + result, err := runHelmCommand(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Helm repo update command failed: %v", err)), nil } @@ -216,7 +288,8 @@ func handleHelmRepoUpdate(ctx context.Context, request mcp.CallToolRequest) (*mc } // Register Helm tools -func RegisterHelmTools(s *server.MCPServer) { +func RegisterTools(s *server.MCPServer, readOnly bool) { + // Read-only tools - always registered s.AddTool(mcp.NewTool("helm_list_releases", mcp.WithDescription("List Helm releases in a namespace"), mcp.WithString("namespace", mcp.Description("The namespace to list releases from")), @@ -229,43 +302,46 @@ func RegisterHelmTools(s *server.MCPServer) { mcp.WithString("pending", mcp.Description("List pending releases")), mcp.WithString("filter", mcp.Description("A regular expression to filter releases by")), mcp.WithString("output", mcp.Description("The output format (e.g., 'json', 'yaml', 'table')")), - ), handleHelmListReleases) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_list_releases", handleHelmListReleases))) s.AddTool(mcp.NewTool("helm_get_release", mcp.WithDescription("Get extended information about a Helm release"), mcp.WithString("name", mcp.Description("The name of the release"), mcp.Required()), mcp.WithString("namespace", mcp.Description("The namespace of the release"), mcp.Required()), mcp.WithString("resource", mcp.Description("The resource to get (all, hooks, manifest, notes, values)")), - ), handleHelmGetRelease) - - s.AddTool(mcp.NewTool("helm_upgrade", - mcp.WithDescription("Upgrade or install a Helm release"), - mcp.WithString("name", mcp.Description("The name of the release"), mcp.Required()), - mcp.WithString("chart", mcp.Description("The chart to install or upgrade to"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the release")), - mcp.WithString("version", mcp.Description("The version of the chart to upgrade to")), - mcp.WithString("values", mcp.Description("Path to a values file")), - mcp.WithString("set", mcp.Description("Set values on the command line (e.g., 'key1=val1,key2=val2')")), - mcp.WithString("install", mcp.Description("Run an install if the release is not present")), - mcp.WithString("dry_run", mcp.Description("Simulate an upgrade")), - mcp.WithString("wait", mcp.Description("Wait for the upgrade to complete")), - ), handleHelmUpgradeRelease) - - s.AddTool(mcp.NewTool("helm_uninstall", - mcp.WithDescription("Uninstall a Helm release"), - mcp.WithString("name", mcp.Description("The name of the release to uninstall"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the release"), mcp.Required()), - mcp.WithString("dry_run", mcp.Description("Simulate an uninstall")), - mcp.WithString("wait", mcp.Description("Wait for the uninstall to complete")), - ), handleHelmUninstall) - - s.AddTool(mcp.NewTool("helm_repo_add", - mcp.WithDescription("Add a Helm repository"), - mcp.WithString("name", mcp.Description("The name of the repository"), mcp.Required()), - mcp.WithString("url", mcp.Description("The URL of the repository"), mcp.Required()), - ), handleHelmRepoAdd) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_get_release", handleHelmGetRelease))) s.AddTool(mcp.NewTool("helm_repo_update", mcp.WithDescription("Update information of available charts locally from chart repositories"), - ), handleHelmRepoUpdate) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_repo_update", handleHelmRepoUpdate))) + + // Write tools - only registered when not in read-only mode + if !readOnly { + s.AddTool(mcp.NewTool("helm_upgrade", + mcp.WithDescription("Upgrade or install a Helm release"), + mcp.WithString("name", mcp.Description("The name of the release"), mcp.Required()), + mcp.WithString("chart", mcp.Description("The chart to install or upgrade to"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace of the release")), + mcp.WithString("version", mcp.Description("The version of the chart to upgrade to")), + mcp.WithString("values", mcp.Description("Path to a values file")), + mcp.WithString("set", mcp.Description("Set values on the command line (e.g., 'key1=val1,key2=val2')")), + mcp.WithString("install", mcp.Description("Run an install if the release is not present")), + mcp.WithString("dry_run", mcp.Description("Simulate an upgrade")), + mcp.WithString("wait", mcp.Description("Wait for the upgrade to complete")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_upgrade", handleHelmUpgradeRelease))) + + s.AddTool(mcp.NewTool("helm_uninstall", + mcp.WithDescription("Uninstall a Helm release"), + mcp.WithString("name", mcp.Description("The name of the release to uninstall"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace of the release"), mcp.Required()), + mcp.WithString("dry_run", mcp.Description("Simulate an uninstall")), + mcp.WithString("wait", mcp.Description("Wait for the uninstall to complete")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_uninstall", handleHelmUninstall))) + + s.AddTool(mcp.NewTool("helm_repo_add", + mcp.WithDescription("Add a Helm repository"), + mcp.WithString("name", mcp.Description("The name of the repository"), mcp.Required()), + mcp.WithString("url", mcp.Description("The URL of the repository"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("helm_repo_add", handleHelmRepoAdd))) + } } diff --git a/pkg/helm/helm_test.go b/pkg/helm/helm_test.go index 4a991657..9e5b26ca 100644 --- a/pkg/helm/helm_test.go +++ b/pkg/helm/helm_test.go @@ -4,129 +4,134 @@ import ( "context" "testing" - "github.com/kagent-dev/tools/pkg/utils" + "github.com/kagent-dev/tools/internal/cmd" "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestRegisterTools(t *testing.T) { + s := server.NewMCPServer("test-server", "v0.0.1") + RegisterTools(s, false) // false = enable all tools including write operations +} + // Test Helm List Releases func TestHandleHelmListReleases(t *testing.T) { - t.Run("basic list releases", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -app1 default 1 2023-01-01 12:00:00.000000000 +0000 UTC deployed myapp-1.0.0 1.0.0 -app2 kube-system 2 2023-01-02 12:00:00.000000000 +0000 UTC deployed system-2.0.0 2.0.0` - - mock.AddCommandString("helm", []string{"list"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - result, err := handleHelmListReleases(ctx, request) - - assert.NoError(t, err) - assert.NotNil(t, result) - assert.False(t, result.IsError) - - // Verify the expected output - content := getResultText(result) - assert.Contains(t, content, "app1") - assert.Contains(t, content, "app2") - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "helm", callLog[0].Command) - assert.Equal(t, []string{"list"}, callLog[0].Args) - }) - - t.Run("list releases with namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - mock.AddCommandString("helm", []string{"list", "-n", "production"}, "production releases", nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "production", - } - - result, err := handleHelmListReleases(ctx, request) - - assert.NoError(t, err) - assert.False(t, result.IsError) - - // Verify the correct command was called with namespace - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "helm", callLog[0].Command) - assert.Equal(t, []string{"list", "-n", "production"}, callLog[0].Args) - }) - - t.Run("list releases with all namespaces", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - mock.AddCommandString("helm", []string{"list", "-A"}, "all namespaces releases", nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "all_namespaces": "true", - } - - result, err := handleHelmListReleases(ctx, request) - - assert.NoError(t, err) - assert.False(t, result.IsError) - - // Verify the correct command was called with -A flag - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "helm", callLog[0].Command) - assert.Equal(t, []string{"list", "-A"}, callLog[0].Args) - }) - - t.Run("list releases with multiple flags", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - mock.AddCommandString("helm", []string{"list", "-A", "-a", "--failed", "-o", "json"}, `[{"name":"failed-app","status":"failed"}]`, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "all_namespaces": "true", - "all": "true", - "failed": "true", - "output": "json", - } - - result, err := handleHelmListReleases(ctx, request) - - assert.NoError(t, err) - assert.False(t, result.IsError) + tests := []struct { + name string + args map[string]interface{} + expectedArgs []string + expectedOutput string + expectError bool + }{ + { + name: "basic_list_releases", + args: map[string]interface{}{}, + expectedArgs: []string{"list"}, + expectedOutput: `NAME NAMESPACE REVISION STATUS CHART +app1 default 1 deployed my-chart-1.0.0 +app2 default 2 deployed my-chart-2.0.0`, + expectError: false, + }, + { + name: "list_releases_with_namespace", + args: map[string]interface{}{ + "namespace": "production", + }, + expectedArgs: []string{"list", "-n", "production"}, + expectedOutput: `NAME NAMESPACE REVISION STATUS CHART +prod-app production 1 deployed my-chart-1.0.0`, + expectError: false, + }, + { + name: "list_releases_with_all_namespaces", + args: map[string]interface{}{ + "all_namespaces": "true", + }, + expectedArgs: []string{"list", "-A"}, + expectedOutput: `NAME NAMESPACE REVISION STATUS CHART +app1 default 1 deployed my-chart-1.0.0 +prod-app production 1 deployed my-chart-1.0.0`, + expectError: false, + }, + { + name: "list_releases_with_multiple_flags", + args: map[string]interface{}{ + "all_namespaces": "true", + "all": "true", + "failed": "true", + "output": "json", + }, + expectedArgs: []string{"list", "-A", "-a", "--failed", "-o", "json"}, + expectedOutput: `[ + { + "name": "app1", + "namespace": "default", + "revision": "1", + "status": "deployed" + } +]`, + expectError: false, + }, + } - // Verify the correct command was called with multiple flags - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "helm", callLog[0].Command) - assert.Equal(t, []string{"list", "-A", "-a", "--failed", "-o", "json"}, callLog[0].Args) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("helm", tt.expectedArgs, tt.expectedOutput, nil) + ctx := cmd.WithShellExecutor(context.Background(), mock) + + request := mcp.CallToolRequest{} + request.Params.Arguments = tt.args + + result, err := handleHelmListReleases(ctx, request) + + assert.NoError(t, err) + assert.False(t, result.IsError) + + // Verify the expected output + content := getResultText(result) + if tt.name == "basic_list_releases" { + assert.Contains(t, content, "app1") + assert.Contains(t, content, "app2") + } else if tt.name == "list_releases_with_namespace" { + assert.Contains(t, content, "prod-app") + assert.Contains(t, content, "production") + } else if tt.name == "list_releases_with_all_namespaces" { + assert.Contains(t, content, "app1") + assert.Contains(t, content, "prod-app") + } else if tt.name == "list_releases_with_multiple_flags" { + assert.Contains(t, content, "app1") + assert.Contains(t, content, "default") + } + + // Verify the correct command was called + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Equal(t, "helm", callLog[0].Command) + assert.Equal(t, tt.expectedArgs, callLog[0].Args) + }) + } t.Run("helm command failure", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() mock.AddCommandString("helm", []string{"list"}, "", assert.AnError) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} result, err := handleHelmListReleases(ctx, request) assert.NoError(t, err) // MCP handlers should not return Go errors assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "Helm list command failed") + assert.Contains(t, getResultText(result), "**Helm Error**") }) } // Test Helm Get Release func TestHandleHelmGetRelease(t *testing.T) { t.Run("get release all resources", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `REVISION: 1 RELEASED: Mon Jan 01 12:00:00 UTC 2023 CHART: myapp-1.0.0 @@ -134,7 +139,7 @@ VALUES: replicaCount: 3` mock.AddCommandString("helm", []string{"get", "all", "myapp", "-n", "default"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -156,9 +161,9 @@ replicaCount: 3` }) t.Run("get release values only", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() mock.AddCommandString("helm", []string{"get", "values", "myapp", "-n", "default"}, "replicaCount: 3", nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -180,8 +185,8 @@ replicaCount: 3` }) t.Run("missing required parameters", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) // Test missing name request := mcp.CallToolRequest{} @@ -213,7 +218,7 @@ replicaCount: 3` // Test Helm Upgrade Release func TestHandleHelmUpgradeRelease(t *testing.T) { t.Run("basic upgrade", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `Release "myapp" has been upgraded. Happy Helming! NAME: myapp LAST DEPLOYED: Mon Jan 01 12:00:00 UTC 2023 @@ -221,8 +226,8 @@ NAMESPACE: default STATUS: deployed REVISION: 2` - mock.AddCommandString("helm", []string{"upgrade", "myapp", "stable/myapp"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + mock.AddCommandString("helm", []string{"upgrade", "myapp", "stable/myapp", "--timeout", "30s"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -240,11 +245,11 @@ REVISION: 2` callLog := mock.GetCallLog() require.Len(t, callLog, 1) assert.Equal(t, "helm", callLog[0].Command) - assert.Equal(t, []string{"upgrade", "myapp", "stable/myapp"}, callLog[0].Args) + assert.Equal(t, []string{"upgrade", "myapp", "stable/myapp", "--timeout", "30s"}, callLog[0].Args) }) t.Run("upgrade with all options", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedArgs := []string{ "upgrade", "myapp", "stable/myapp", "-n", "production", @@ -255,9 +260,10 @@ REVISION: 2` "--install", "--dry-run", "--wait", + "--timeout", "30s", } - mock.AddCommandString("helm", expectedArgs, "dry run output", nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + mock.AddCommandString("helm", expectedArgs, "Upgraded with options", nil) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -284,14 +290,14 @@ REVISION: 2` assert.Equal(t, expectedArgs, callLog[0].Args) }) - t.Run("missing required parameters", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + t.Run("missing required parameters for upgrade", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + // Test missing chart request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ "name": "myapp", - // Missing chart } result, err := handleHelmUpgradeRelease(ctx, request) @@ -308,11 +314,11 @@ REVISION: 2` // Test Helm Uninstall func TestHandleHelmUninstall(t *testing.T) { t.Run("basic uninstall", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `release "myapp" uninstalled` mock.AddCommandString("helm", []string{"uninstall", "myapp", "-n", "default"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -323,6 +329,7 @@ func TestHandleHelmUninstall(t *testing.T) { result, err := handleHelmUninstall(ctx, request) assert.NoError(t, err) + assert.NotNil(t, result) assert.False(t, result.IsError) assert.Contains(t, getResultText(result), "uninstalled") @@ -334,10 +341,11 @@ func TestHandleHelmUninstall(t *testing.T) { }) t.Run("uninstall with options", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedArgs := []string{"uninstall", "myapp", "-n", "production", "--dry-run", "--wait"} - mock.AddCommandString("helm", expectedArgs, "dry run uninstall", nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + mock := cmd.NewMockShellExecutor() + expectedOutput := `release "myapp" uninstalled` + + mock.AddCommandString("helm", []string{"uninstall", "myapp", "-n", "production", "--dry-run", "--wait"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -356,23 +364,53 @@ func TestHandleHelmUninstall(t *testing.T) { callLog := mock.GetCallLog() require.Len(t, callLog, 1) assert.Equal(t, "helm", callLog[0].Command) - assert.Equal(t, expectedArgs, callLog[0].Args) + assert.Equal(t, []string{"uninstall", "myapp", "-n", "production", "--dry-run", "--wait"}, callLog[0].Args) + }) + + t.Run("missing required parameters for uninstall", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + // Test missing name + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "namespace": "default", + } + + result, err := handleHelmUninstall(ctx, request) + assert.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "name and namespace parameters are required") + + // Test missing namespace + request.Params.Arguments = map[string]interface{}{ + "name": "myapp", + } + + result, err = handleHelmUninstall(ctx, request) + assert.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "name and namespace parameters are required") + + // Verify no commands were executed + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) } // Test Helm Repo Add func TestHandleHelmRepoAdd(t *testing.T) { - t.Run("add repository", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `"stable" has been added to your repositories` + t.Run("basic repo add", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `"my-repo" has been added to your repositories` - mock.AddCommandString("helm", []string{"repo", "add", "stable", "https://charts.helm.sh/stable"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + mock.AddCommandString("helm", []string{"repo", "add", "my-repo", "https://charts.example.com/"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "name": "stable", - "url": "https://charts.helm.sh/stable", + "name": "my-repo", + "url": "https://charts.example.com/", } result, err := handleHelmRepoAdd(ctx, request) @@ -385,17 +423,17 @@ func TestHandleHelmRepoAdd(t *testing.T) { callLog := mock.GetCallLog() require.Len(t, callLog, 1) assert.Equal(t, "helm", callLog[0].Command) - assert.Equal(t, []string{"repo", "add", "stable", "https://charts.helm.sh/stable"}, callLog[0].Args) + assert.Equal(t, []string{"repo", "add", "my-repo", "https://charts.example.com/"}, callLog[0].Args) }) - t.Run("missing required parameters", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + t.Run("missing required parameters for repo add", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + // Test missing name request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "name": "stable", - // Missing url + "url": "https://charts.example.com/", } result, err := handleHelmRepoAdd(ctx, request) @@ -411,21 +449,21 @@ func TestHandleHelmRepoAdd(t *testing.T) { // Test Helm Repo Update func TestHandleHelmRepoUpdate(t *testing.T) { - t.Run("update repositories", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + t.Run("basic repo update", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() expectedOutput := `Hang tight while we grab the latest from your chart repositories... ...Successfully got an update from the "stable" chart repository Update Complete. ⎈Happy Helming!⎈` mock.AddCommandString("helm", []string{"repo", "update"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(context.Background(), mock) request := mcp.CallToolRequest{} result, err := handleHelmRepoUpdate(ctx, request) assert.NoError(t, err) assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "Update Complete") + assert.Contains(t, getResultText(result), "Successfully got an update") // Verify the correct command was called callLog := mock.GetCallLog() diff --git a/pkg/istio/istio.go b/pkg/istio/istio.go index 2c7b68cc..dd1958c9 100644 --- a/pkg/istio/istio.go +++ b/pkg/istio/istio.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/kagent-dev/tools/internal/commands" + "github.com/kagent-dev/tools/internal/telemetry" "github.com/kagent-dev/tools/pkg/utils" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -25,7 +27,7 @@ func handleIstioProxyStatus(ctx context.Context, request mcp.CallToolRequest) (* args = append(args, podName) } - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl proxy-status failed: %v", err)), nil } @@ -33,6 +35,14 @@ func handleIstioProxyStatus(ctx context.Context, request mcp.CallToolRequest) (* return mcp.NewToolResultText(result), nil } +func runIstioCtl(ctx context.Context, args []string) (string, error) { + kubeconfigPath := utils.GetKubeconfig() + return commands.NewCommandBuilder("istioctl"). + WithArgs(args...). + WithKubeconfig(kubeconfigPath). + Execute(ctx) +} + // Istio proxy config func handleIstioProxyConfig(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { podName := mcp.ParseString(request, "pod_name", "") @@ -51,7 +61,7 @@ func handleIstioProxyConfig(ctx context.Context, request mcp.CallToolRequest) (* args = append(args, podName) } - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl proxy-config failed: %v", err)), nil } @@ -65,7 +75,7 @@ func handleIstioInstall(ctx context.Context, request mcp.CallToolRequest) (*mcp. args := []string{"install", "--set", fmt.Sprintf("profile=%s", profile), "-y"} - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl install failed: %v", err)), nil } @@ -79,7 +89,7 @@ func handleIstioGenerateManifest(ctx context.Context, request mcp.CallToolReques args := []string{"manifest", "generate", "--set", fmt.Sprintf("profile=%s", profile)} - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl manifest generate failed: %v", err)), nil } @@ -100,7 +110,7 @@ func handleIstioAnalyzeClusterConfiguration(ctx context.Context, request mcp.Cal args = append(args, "-n", namespace) } - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl analyze failed: %v", err)), nil } @@ -118,7 +128,7 @@ func handleIstioVersion(ctx context.Context, request mcp.CallToolRequest) (*mcp. args = append(args, "--short") } - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl version failed: %v", err)), nil } @@ -130,7 +140,7 @@ func handleIstioVersion(ctx context.Context, request mcp.CallToolRequest) (*mcp. func handleIstioRemoteClusters(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := []string{"remote-clusters"} - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl remote-clusters failed: %v", err)), nil } @@ -151,7 +161,7 @@ func handleWaypointList(ctx context.Context, request mcp.CallToolRequest) (*mcp. args = append(args, "-n", namespace) } - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint list failed: %v", err)), nil } @@ -181,7 +191,7 @@ func handleWaypointGenerate(ctx context.Context, request mcp.CallToolRequest) (* args = append(args, "--for", trafficType) } - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint generate failed: %v", err)), nil } @@ -204,7 +214,7 @@ func handleWaypointApply(ctx context.Context, request mcp.CallToolRequest) (*mcp args = append(args, "--enroll-namespace") } - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint apply failed: %v", err)), nil } @@ -235,7 +245,7 @@ func handleWaypointDelete(ctx context.Context, request mcp.CallToolRequest) (*mc args = append(args, "-n", namespace) - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint delete failed: %v", err)), nil } @@ -260,7 +270,7 @@ func handleWaypointStatus(ctx context.Context, request mcp.CallToolRequest) (*mc args = append(args, "-n", namespace) - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("istioctl waypoint status failed: %v", err)), nil } @@ -273,28 +283,30 @@ func handleZtunnelConfig(ctx context.Context, request mcp.CallToolRequest) (*mcp namespace := mcp.ParseString(request, "namespace", "") configType := mcp.ParseString(request, "config_type", "all") - args := []string{"ztunnel-config", configType} + args := []string{"ztunnel", "config", configType} if namespace != "" { args = append(args, "-n", namespace) } - result, err := utils.RunCommandWithContext(ctx, "istioctl", args) + result, err := runIstioCtl(ctx, args) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("istioctl ztunnel-config failed: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("istioctl ztunnel config failed: %v", err)), nil } return mcp.NewToolResultText(result), nil } // Register Istio tools -func RegisterIstioTools(s *server.MCPServer) { +func RegisterTools(s *server.MCPServer, readOnly bool) { + // Read-only tools - always registered + // Istio proxy status s.AddTool(mcp.NewTool("istio_proxy_status", mcp.WithDescription("Get Envoy proxy status for pods, retrieves last sent and acknowledged xDS sync from Istiod to each Envoy in the mesh"), mcp.WithString("pod_name", mcp.Description("Name of the pod to get proxy status for")), mcp.WithString("namespace", mcp.Description("Namespace of the pod")), - ), handleIstioProxyStatus) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_proxy_status", handleIstioProxyStatus))) // Istio proxy config s.AddTool(mcp.NewTool("istio_proxy_config", @@ -302,79 +314,65 @@ func RegisterIstioTools(s *server.MCPServer) { mcp.WithString("pod_name", mcp.Description("Name of the pod to get proxy configuration for"), mcp.Required()), mcp.WithString("namespace", mcp.Description("Namespace of the pod")), mcp.WithString("config_type", mcp.Description("Type of configuration (all, bootstrap, cluster, ecds, listener, log, route, secret)")), - ), handleIstioProxyConfig) - - // Istio install - s.AddTool(mcp.NewTool("istio_install_istio", - mcp.WithDescription("Install Istio with a specified configuration profile"), - mcp.WithString("profile", mcp.Description("Istio configuration profile (ambient, default, demo, minimal, empty)")), - ), handleIstioInstall) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_proxy_config", handleIstioProxyConfig))) - // Istio generate manifest + // Istio generate manifest (read-only - just generates YAML, doesn't apply) s.AddTool(mcp.NewTool("istio_generate_manifest", - mcp.WithDescription("Generate an Istio install manifest"), + mcp.WithDescription("Generate Istio manifest for a given profile"), mcp.WithString("profile", mcp.Description("Istio configuration profile (ambient, default, demo, minimal, empty)")), - ), handleIstioGenerateManifest) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_generate_manifest", handleIstioGenerateManifest))) // Istio analyze s.AddTool(mcp.NewTool("istio_analyze_cluster_configuration", - mcp.WithDescription("Analyze live cluster configuration for potential issues"), - mcp.WithString("namespace", mcp.Description("Namespace to analyze")), - mcp.WithString("all_namespaces", mcp.Description("Analyze all namespaces (true/false)")), - ), handleIstioAnalyzeClusterConfiguration) + mcp.WithDescription("Analyze Istio cluster configuration for issues"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_analyze_cluster_configuration", handleIstioAnalyzeClusterConfiguration))) // Istio version s.AddTool(mcp.NewTool("istio_version", - mcp.WithDescription("Get Istio CLI client version, control plane and data plane versions"), - mcp.WithString("short", mcp.Description("Show short version format (true/false)")), - ), handleIstioVersion) + mcp.WithDescription("Get Istio version information"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_version", handleIstioVersion))) // Istio remote clusters s.AddTool(mcp.NewTool("istio_remote_clusters", - mcp.WithDescription("List remote clusters each istiod instance is connected to"), - ), handleIstioRemoteClusters) + mcp.WithDescription("List remote clusters registered with Istio"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_remote_clusters", handleIstioRemoteClusters))) // Waypoint list s.AddTool(mcp.NewTool("istio_list_waypoints", - mcp.WithDescription("List managed waypoint configurations in the cluster"), - mcp.WithString("namespace", mcp.Description("Namespace to list waypoints for")), - mcp.WithString("all_namespaces", mcp.Description("List waypoints for all namespaces (true/false)")), - ), handleWaypointList) + mcp.WithDescription("List all waypoints in the mesh"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_list_waypoints", handleWaypointList))) - // Waypoint generate + // Waypoint generate (read-only - just generates YAML, doesn't apply) s.AddTool(mcp.NewTool("istio_generate_waypoint", - mcp.WithDescription("Generate a waypoint configuration as YAML"), - mcp.WithString("namespace", mcp.Description("Namespace to generate the waypoint for"), mcp.Required()), - mcp.WithString("name", mcp.Description("Name of the waypoint to generate")), - mcp.WithString("traffic_type", mcp.Description("Traffic type for the waypoint (all, inbound, outbound)")), - ), handleWaypointGenerate) - - // Waypoint apply - s.AddTool(mcp.NewTool("istio_apply_waypoint", - mcp.WithDescription("Apply a waypoint configuration to a cluster"), - mcp.WithString("namespace", mcp.Description("Namespace to apply the waypoint to"), mcp.Required()), - mcp.WithString("enroll_namespace", mcp.Description("Label the namespace with the waypoint name (true/false)")), - ), handleWaypointApply) - - // Waypoint delete - s.AddTool(mcp.NewTool("istio_delete_waypoint", - mcp.WithDescription("Delete waypoint configurations from a cluster"), - mcp.WithString("namespace", mcp.Description("Namespace to delete waypoints from"), mcp.Required()), - mcp.WithString("names", mcp.Description("Comma-separated list of waypoint names to delete")), - mcp.WithString("all", mcp.Description("Delete all waypoints in the namespace (true/false)")), - ), handleWaypointDelete) + mcp.WithDescription("Generate a waypoint resource YAML"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_generate_waypoint", handleWaypointGenerate))) // Waypoint status s.AddTool(mcp.NewTool("istio_waypoint_status", - mcp.WithDescription("Get status of a waypoint"), - mcp.WithString("namespace", mcp.Description("Namespace of the waypoint"), mcp.Required()), - mcp.WithString("name", mcp.Description("Name of the waypoint to get status for")), - ), handleWaypointStatus) + mcp.WithDescription("Get the status of a waypoint resource"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_waypoint_status", handleWaypointStatus))) // Ztunnel config s.AddTool(mcp.NewTool("istio_ztunnel_config", - mcp.WithDescription("Get ztunnel configuration"), - mcp.WithString("namespace", mcp.Description("Namespace of the pod")), - mcp.WithString("config_type", mcp.Description("Type of configuration (all, bootstrap, cluster, ecds, listener, log, route, secret)")), - ), handleZtunnelConfig) + mcp.WithDescription("Get the ztunnel configuration for a namespace"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_ztunnel_config", handleZtunnelConfig))) + + // Write tools - only registered when write operations are enabled + if !readOnly { + // Istio install + s.AddTool(mcp.NewTool("istio_install_istio", + mcp.WithDescription("Install Istio with a specified configuration profile"), + mcp.WithString("profile", mcp.Description("Istio configuration profile (ambient, default, demo, minimal, empty)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_install_istio", handleIstioInstall))) + + // Waypoint apply + s.AddTool(mcp.NewTool("istio_apply_waypoint", + mcp.WithDescription("Apply a waypoint resource to the cluster"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_apply_waypoint", handleWaypointApply))) + + // Waypoint delete + s.AddTool(mcp.NewTool("istio_delete_waypoint", + mcp.WithDescription("Delete a waypoint resource from the cluster"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("istio_delete_waypoint", handleWaypointDelete))) + } } diff --git a/pkg/istio/istio_test.go b/pkg/istio/istio_test.go index 2adaf999..36abeef9 100644 --- a/pkg/istio/istio_test.go +++ b/pkg/istio/istio_test.go @@ -4,299 +4,185 @@ import ( "context" "testing" - "github.com/kagent-dev/tools/pkg/utils" + "github.com/kagent-dev/tools/internal/cmd" "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// Helper function to extract text content from MCP result -func getResultText(result *mcp.CallToolResult) string { - if result == nil || len(result.Content) == 0 { - return "" - } - if textContent, ok := result.Content[0].(mcp.TextContent); ok { - return textContent.Text - } - return "" +func TestRegisterTools(t *testing.T) { + s := server.NewMCPServer("test-server", "v0.0.1") + RegisterTools(s, false) // false = enable all tools including write operations } -// Test Istio Proxy Status func TestHandleIstioProxyStatus(t *testing.T) { + ctx := context.Background() + t.Run("basic proxy status", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `NAME CDS LDS EDS RDS ISTIOD VERSION -app-1 SYNCED SYNCED SYNCED SYNCED istiod-68d5d5b5fc-7vf6n 1.18.0 -app-2 SYNCED SYNCED SYNCED SYNCED istiod-68d5d5b5fc-7vf6n 1.18.0` + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"proxy-status"}, "Proxy status output", nil) - mock.AddCommandString("istioctl", []string{"proxy-status"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) - request := mcp.CallToolRequest{} - result, err := handleIstioProxyStatus(ctx, request) + result, err := handleIstioProxyStatus(ctx, mcp.CallToolRequest{}) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) - - // Verify the expected output - content := getResultText(result) - assert.Contains(t, content, "app-1") - assert.Contains(t, content, "SYNCED") - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"proxy-status"}, callLog[0].Args) }) t.Run("proxy status with namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `NAME CDS LDS EDS RDS ISTIOD VERSION -app-1 SYNCED SYNCED SYNCED SYNCED istiod-68d5d5b5fc-7vf6n 1.18.0` + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"proxy-status", "-n", "istio-system"}, "Proxy status output", nil) - mock.AddCommandString("istioctl", []string{"proxy-status", "-n", "production"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "namespace": "production", + "namespace": "istio-system", } result, err := handleIstioProxyStatus(ctx, request) - assert.NoError(t, err) + require.NoError(t, err) + assert.NotNil(t, result) assert.False(t, result.IsError) - - // Verify the correct command was called with namespace - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"proxy-status", "-n", "production"}, callLog[0].Args) }) - t.Run("proxy status with pod name and namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `NAME CDS LDS EDS RDS ISTIOD VERSION -app-1 SYNCED SYNCED SYNCED SYNCED istiod-68d5d5b5fc-7vf6n 1.18.0` + t.Run("proxy status with pod name", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"proxy-status", "-n", "default", "test-pod"}, "Proxy status output", nil) - mock.AddCommandString("istioctl", []string{"proxy-status", "-n", "production", "app-1"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "namespace": "production", - "pod_name": "app-1", + "pod_name": "test-pod", + "namespace": "default", } result, err := handleIstioProxyStatus(ctx, request) - assert.NoError(t, err) + require.NoError(t, err) + assert.NotNil(t, result) assert.False(t, result.IsError) - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"proxy-status", "-n", "production", "app-1"}, callLog[0].Args) }) +} - t.Run("istioctl command failure", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - mock.AddCommandString("istioctl", []string{"proxy-status"}, "", assert.AnError) - ctx := utils.WithShellExecutor(context.Background(), mock) +func TestHandleIstioProxyConfig(t *testing.T) { + ctx := context.Background() - request := mcp.CallToolRequest{} - result, err := handleIstioProxyStatus(ctx, request) + t.Run("missing pod_name parameter", func(t *testing.T) { + result, err := handleIstioProxyConfig(ctx, mcp.CallToolRequest{}) - assert.NoError(t, err) // MCP handlers should not return Go errors + require.NoError(t, err) + assert.NotNil(t, result) assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "istioctl proxy-status failed") }) -} -// Test Istio Proxy Config -func TestHandleIstioProxyConfig(t *testing.T) { - t.Run("proxy config all", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `CLUSTER NAME DIRECTION TYPE DESTINATION RULE -outbound|80||kubernetes.default.svc.cluster.local outbound EDS -inbound|80|| inbound EDS` + t.Run("proxy config with pod name", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"proxy-config", "all", "test-pod"}, "Proxy config output", nil) - mock.AddCommandString("istioctl", []string{"proxy-config", "all", "app-1"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "pod_name": "app-1", + "pod_name": "test-pod", } result, err := handleIstioProxyConfig(ctx, request) - assert.NoError(t, err) + require.NoError(t, err) + assert.NotNil(t, result) assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "CLUSTER NAME") - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"proxy-config", "all", "app-1"}, callLog[0].Args) }) - t.Run("proxy config with namespace and config type", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `CLUSTER NAME DIRECTION TYPE DESTINATION RULE -outbound|80||kubernetes.default.svc.cluster.local outbound EDS` + t.Run("proxy config with namespace", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"proxy-config", "cluster", "test-pod.default"}, "Proxy config output", nil) - mock.AddCommandString("istioctl", []string{"proxy-config", "cluster", "app-1.production"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "pod_name": "app-1", - "namespace": "production", + "pod_name": "test-pod", + "namespace": "default", "config_type": "cluster", } result, err := handleIstioProxyConfig(ctx, request) - assert.NoError(t, err) + require.NoError(t, err) + assert.NotNil(t, result) assert.False(t, result.IsError) - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"proxy-config", "cluster", "app-1.production"}, callLog[0].Args) - }) - - t.Run("missing required parameters", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - // Missing pod_name - } - - result, err := handleIstioProxyConfig(ctx, request) - assert.NoError(t, err) - assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "pod_name parameter is required") - - // Verify no commands were executed - callLog := mock.GetCallLog() - assert.Len(t, callLog, 0) }) } -// Test Istio Install func TestHandleIstioInstall(t *testing.T) { - t.Run("basic install", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `✔ Istio core installed -✔ Istiod installed -✔ Ingress gateways installed -✔ Installation complete` + ctx := context.Background() - mock.AddCommandString("istioctl", []string{"install", "--set", "profile=default", "-y"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + t.Run("install with default profile", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"install", "--set", "profile=default", "-y"}, "Install completed", nil) - request := mcp.CallToolRequest{} - result, err := handleIstioInstall(ctx, request) + ctx = cmd.WithShellExecutor(ctx, mock) - assert.NoError(t, err) - assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "Installation complete") + result, err := handleIstioInstall(ctx, mcp.CallToolRequest{}) - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"install", "--set", "profile=default", "-y"}, callLog[0].Args) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) }) - t.Run("install with profile", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `✔ Istio core installed -✔ Installation complete` + t.Run("install with custom profile", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"install", "--set", "profile=demo", "-y"}, "Install completed", nil) - mock.AddCommandString("istioctl", []string{"install", "--set", "profile=minimal", "-y"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "profile": "minimal", + "profile": "demo", } result, err := handleIstioInstall(ctx, request) - assert.NoError(t, err) + require.NoError(t, err) + assert.NotNil(t, result) assert.False(t, result.IsError) - - // Verify the correct command was called with profile - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"install", "--set", "profile=minimal", "-y"}, callLog[0].Args) }) } -// Test Istio Analyze -func TestHandleIstioAnalyzeClusterConfiguration(t *testing.T) { - t.Run("basic analyze", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `✔ No validation issues found when analyzing namespace: default.` - - mock.AddCommandString("istioctl", []string{"analyze"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - result, err := handleIstioAnalyzeClusterConfiguration(ctx, request) +func TestHandleIstioGenerateManifest(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() - assert.NoError(t, err) - assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "No validation issues found") + mock.AddCommandString("istioctl", []string{"manifest", "generate", "--set", "profile=minimal"}, "Generated manifest", nil) - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"analyze"}, callLog[0].Args) - }) + ctx = cmd.WithShellExecutor(ctx, mock) - t.Run("analyze with namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `✔ No validation issues found when analyzing namespace: production.` - - mock.AddCommandString("istioctl", []string{"analyze", "-n", "production"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "production", - } + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "profile": "minimal", + } - result, err := handleIstioAnalyzeClusterConfiguration(ctx, request) + result, err := handleIstioGenerateManifest(ctx, request) - assert.NoError(t, err) - assert.False(t, result.IsError) + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) +} - // Verify the correct command was called with namespace - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"analyze", "-n", "production"}, callLog[0].Args) - }) +func TestHandleIstioAnalyzeClusterConfiguration(t *testing.T) { + ctx := context.Background() t.Run("analyze all namespaces", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `✔ No validation issues found when analyzing all namespaces.` + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"analyze", "-A"}, "Analysis output", nil) - mock.AddCommandString("istioctl", []string{"analyze", "-A"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ @@ -305,565 +191,167 @@ func TestHandleIstioAnalyzeClusterConfiguration(t *testing.T) { result, err := handleIstioAnalyzeClusterConfiguration(ctx, request) - assert.NoError(t, err) - assert.False(t, result.IsError) - - // Verify the correct command was called with -A flag - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"analyze", "-A"}, callLog[0].Args) - }) -} - -// Test Istio Version -func TestHandleIstioVersion(t *testing.T) { - t.Run("version detailed output", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `client version: 1.18.0 -control plane version: 1.18.0 -data plane version: 1.18.0 (2 proxies)` - - mock.AddCommandString("istioctl", []string{"version"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - result, err := handleIstioVersion(ctx, request) - - assert.NoError(t, err) - assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "client version: 1.18.0") - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"version"}, callLog[0].Args) - }) - - t.Run("version short output", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `1.18.0` - - mock.AddCommandString("istioctl", []string{"version", "--short"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "short": "true", - } - - result, err := handleIstioVersion(ctx, request) - - assert.NoError(t, err) - assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "1.18.0") - - // Verify the correct command was called with --short flag - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"version", "--short"}, callLog[0].Args) - }) -} - -// Test Waypoint List -func TestHandleWaypointList(t *testing.T) { - t.Run("list waypoints", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `NAMESPACE NAME TRAFFIC TYPE -default waypoint ALL -production waypoint INBOUND` - - mock.AddCommandString("istioctl", []string{"waypoint", "list"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - result, err := handleWaypointList(ctx, request) - - assert.NoError(t, err) - assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "NAMESPACE") - assert.Contains(t, getResultText(result), "waypoint") - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"waypoint", "list"}, callLog[0].Args) - }) - - t.Run("list waypoints in namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `NAMESPACE NAME TRAFFIC TYPE -production waypoint INBOUND` - - mock.AddCommandString("istioctl", []string{"waypoint", "list", "-n", "production"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "production", - } - - result, err := handleWaypointList(ctx, request) - - assert.NoError(t, err) - assert.False(t, result.IsError) - - // Verify the correct command was called with namespace - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"waypoint", "list", "-n", "production"}, callLog[0].Args) - }) -} - -// Test Waypoint Generate -func TestHandleWaypointGenerate(t *testing.T) { - t.Run("generate waypoint", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `apiVersion: gateway.networking.k8s.io/v1beta1 -kind: Gateway -metadata: - name: waypoint - namespace: production -spec: - gatewayClassName: istio-waypoint` - - mock.AddCommandString("istioctl", []string{"waypoint", "generate", "waypoint", "-n", "production", "--for", "all"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "production", - } - - result, err := handleWaypointGenerate(ctx, request) - - assert.NoError(t, err) + require.NoError(t, err) + assert.NotNil(t, result) assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "apiVersion: gateway.networking.k8s.io/v1beta1") - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"waypoint", "generate", "waypoint", "-n", "production", "--for", "all"}, callLog[0].Args) - }) - - t.Run("missing required parameters", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - // Missing namespace - } - - result, err := handleWaypointGenerate(ctx, request) - assert.NoError(t, err) - assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "namespace parameter is required") - - // Verify no commands were executed - callLog := mock.GetCallLog() - assert.Len(t, callLog, 0) }) -} -// Test Waypoint Apply -func TestHandleWaypointApply(t *testing.T) { - t.Run("basic waypoint apply", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `waypoint/waypoint applied` + t.Run("analyze specific namespace", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"analyze", "-n", "default"}, "Analysis output", nil) - mock.AddCommandString("istioctl", []string{"waypoint", "apply", "-n", "default"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ "namespace": "default", } - result, err := handleWaypointApply(ctx, request) + result, err := handleIstioAnalyzeClusterConfiguration(ctx, request) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "applied") - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"waypoint", "apply", "-n", "default"}, callLog[0].Args) - }) - - t.Run("waypoint apply with enroll namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `waypoint/waypoint applied -namespace/default labeled with istio.io/use-waypoint=waypoint` - - mock.AddCommandString("istioctl", []string{"waypoint", "apply", "-n", "default", "--enroll-namespace"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - "enroll_namespace": "true", - } - - result, err := handleWaypointApply(ctx, request) - - assert.NoError(t, err) - assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "applied") - - // Verify the correct command was called with --enroll-namespace flag - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"waypoint", "apply", "-n", "default", "--enroll-namespace"}, callLog[0].Args) - }) - - t.Run("missing namespace parameter", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - // Missing namespace - } - - result, err := handleWaypointApply(ctx, request) - assert.NoError(t, err) - assert.NotNil(t, result) - assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "namespace parameter is required") - - // Verify no commands were executed - callLog := mock.GetCallLog() - assert.Len(t, callLog, 0) - }) - - t.Run("istioctl command failure", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - mock.AddCommandString("istioctl", []string{"waypoint", "apply", "-n", "default"}, "", assert.AnError) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - } - - result, err := handleWaypointApply(ctx, request) - - assert.NoError(t, err) // MCP handlers should not return Go errors - assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "istioctl waypoint apply failed") }) } -// Test Waypoint Delete -func TestHandleWaypointDelete(t *testing.T) { - t.Run("delete all waypoints", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `waypoint/waypoint deleted` +func TestHandleIstioVersion(t *testing.T) { + ctx := context.Background() - mock.AddCommandString("istioctl", []string{"waypoint", "delete", "--all", "-n", "default"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + t.Run("version full", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"version"}, "Version output", nil) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - "all": "true", - } + ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleWaypointDelete(ctx, request) + result, err := handleIstioVersion(ctx, mcp.CallToolRequest{}) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "deleted") - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"waypoint", "delete", "--all", "-n", "default"}, callLog[0].Args) }) - t.Run("delete specific waypoints", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `waypoint/waypoint1 deleted -waypoint/waypoint2 deleted` + t.Run("version short", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"version", "--short"}, "1.18.0", nil) - mock.AddCommandString("istioctl", []string{"waypoint", "delete", "waypoint1", "waypoint2", "-n", "default"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - "names": "waypoint1,waypoint2", + "short": "true", } - result, err := handleWaypointDelete(ctx, request) - - assert.NoError(t, err) - assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "deleted") - - // Verify the correct command was called with specific names - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"waypoint", "delete", "waypoint1", "waypoint2", "-n", "default"}, callLog[0].Args) - }) - - t.Run("missing namespace parameter", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - // Missing namespace - } + result, err := handleIstioVersion(ctx, request) - result, err := handleWaypointDelete(ctx, request) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, result) - assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "namespace parameter is required") - - // Verify no commands were executed - callLog := mock.GetCallLog() - assert.Len(t, callLog, 0) - }) - - t.Run("istioctl command failure", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - mock.AddCommandString("istioctl", []string{"waypoint", "delete", "--all", "-n", "default"}, "", assert.AnError) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - "all": "true", - } - - result, err := handleWaypointDelete(ctx, request) - - assert.NoError(t, err) // MCP handlers should not return Go errors - assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "istioctl waypoint delete failed") + assert.False(t, result.IsError) }) } -// Test Waypoint Status -func TestHandleWaypointStatus(t *testing.T) { - t.Run("waypoint status", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `waypoint/waypoint is deployed and ready` +func TestHandleIstioRemoteClusters(t *testing.T) { + ctx := context.Background() + mock := cmd.NewMockShellExecutor() - mock.AddCommandString("istioctl", []string{"waypoint", "status", "-n", "default"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + mock.AddCommandString("istioctl", []string{"remote-clusters"}, "Remote clusters output", nil) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - } + ctx = cmd.WithShellExecutor(ctx, mock) - result, err := handleWaypointStatus(ctx, request) + result, err := handleIstioRemoteClusters(ctx, mcp.CallToolRequest{}) - assert.NoError(t, err) - assert.NotNil(t, result) - assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "waypoint") + require.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) +} - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"waypoint", "status", "-n", "default"}, callLog[0].Args) - }) +func TestHandleWaypointList(t *testing.T) { + ctx := context.Background() - t.Run("waypoint status with specific name", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `waypoint/test-waypoint is deployed and ready` + t.Run("list waypoints in all namespaces", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"waypoint", "list", "-A"}, "Waypoints list", nil) - mock.AddCommandString("istioctl", []string{"waypoint", "status", "test-waypoint", "-n", "default"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "namespace": "default", - "name": "test-waypoint", + "all_namespaces": "true", } - result, err := handleWaypointStatus(ctx, request) + result, err := handleWaypointList(ctx, request) - assert.NoError(t, err) + require.NoError(t, err) + assert.NotNil(t, result) assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "test-waypoint") - - // Verify the correct command was called with specific name - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"waypoint", "status", "test-waypoint", "-n", "default"}, callLog[0].Args) }) - t.Run("missing namespace parameter", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + t.Run("list waypoints in a specific namespace", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"waypoint", "list", "-n", "default"}, "Waypoints list", nil) - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - // Missing namespace - } - - result, err := handleWaypointStatus(ctx, request) - assert.NoError(t, err) - assert.NotNil(t, result) - assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "namespace parameter is required") - - // Verify no commands were executed - callLog := mock.GetCallLog() - assert.Len(t, callLog, 0) - }) - - t.Run("istioctl command failure", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - mock.AddCommandString("istioctl", []string{"waypoint", "status", "-n", "default"}, "", assert.AnError) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ "namespace": "default", } - result, err := handleWaypointStatus(ctx, request) - - assert.NoError(t, err) // MCP handlers should not return Go errors - assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "istioctl waypoint status failed") - }) -} - -// Test Ztunnel Config -func TestHandleZtunnelConfig(t *testing.T) { - t.Run("default ztunnel config", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `CLUSTER_NAME CLUSTER_TYPE ENDPOINTS -cluster1 EDS 10.0.0.1:15010 -cluster2 STATIC 10.0.0.2:15010` - - mock.AddCommandString("istioctl", []string{"ztunnel-config", "all"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - result, err := handleZtunnelConfig(ctx, request) + result, err := handleWaypointList(ctx, request) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) - assert.Contains(t, getResultText(result), "CLUSTER_NAME") - - // Verify the correct command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"ztunnel-config", "all"}, callLog[0].Args) }) +} - t.Run("ztunnel config with namespace", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `CLUSTER_NAME CLUSTER_TYPE ENDPOINTS -cluster1 EDS 10.0.0.1:15010` - - mock.AddCommandString("istioctl", []string{"ztunnel-config", "all", "-n", "istio-system"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "istio-system", - } - - result, err := handleZtunnelConfig(ctx, request) - - assert.NoError(t, err) - assert.False(t, result.IsError) - - // Verify the correct command was called with namespace - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"ztunnel-config", "all", "-n", "istio-system"}, callLog[0].Args) - }) +func TestHandleWaypointGenerate(t *testing.T) { + ctx := context.Background() - t.Run("ztunnel config with specific type", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `CLUSTER_NAME CLUSTER_TYPE ENDPOINTS -cluster1 EDS 10.0.0.1:15010` + t.Run("generate waypoint with namespace", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"waypoint", "generate", "waypoint", "-n", "default", "--for", "all"}, "Generated waypoint", nil) - mock.AddCommandString("istioctl", []string{"ztunnel-config", "cluster"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx = cmd.WithShellExecutor(ctx, mock) request := mcp.CallToolRequest{} request.Params.Arguments = map[string]interface{}{ - "config_type": "cluster", + "namespace": "default", + "name": "waypoint", + "traffic_type": "all", } - result, err := handleZtunnelConfig(ctx, request) + result, err := handleWaypointGenerate(ctx, request) - assert.NoError(t, err) + require.NoError(t, err) + assert.NotNil(t, result) assert.False(t, result.IsError) - - // Verify the correct command was called with specific config type - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"ztunnel-config", "cluster"}, callLog[0].Args) }) +} - t.Run("ztunnel config with namespace and config type", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `LISTENER_NAME ADDRESS PORT TYPE -listener1 0.0.0.0 15006 TCP` - - mock.AddCommandString("istioctl", []string{"ztunnel-config", "listener", "-n", "istio-system"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - request := mcp.CallToolRequest{} - request.Params.Arguments = map[string]interface{}{ - "namespace": "istio-system", - "config_type": "listener", - } - - result, err := handleZtunnelConfig(ctx, request) +func TestRunIstioCtl(t *testing.T) { + t.Run("run istioctl with context", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"version"}, "1.18.0", nil) + ctx := cmd.WithShellExecutor(context.Background(), mock) - assert.NoError(t, err) - assert.False(t, result.IsError) + result, err := runIstioCtl(ctx, []string{"version"}) - // Verify the correct command was called with both namespace and config type - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "istioctl", callLog[0].Command) - assert.Equal(t, []string{"ztunnel-config", "listener", "-n", "istio-system"}, callLog[0].Args) + require.NoError(t, err) + assert.Equal(t, "1.18.0", result) }) +} +func TestIstioErrorHandling(t *testing.T) { t.Run("istioctl command failure", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - mock.AddCommandString("istioctl", []string{"ztunnel-config", "all"}, "", assert.AnError) - ctx := utils.WithShellExecutor(context.Background(), mock) + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("istioctl", []string{"proxy-status"}, "", assert.AnError) + ctx := cmd.WithShellExecutor(context.Background(), mock) - request := mcp.CallToolRequest{} - result, err := handleZtunnelConfig(ctx, request) + result, err := handleIstioProxyStatus(ctx, mcp.CallToolRequest{}) - assert.NoError(t, err) // MCP handlers should not return Go errors + require.NoError(t, err) + assert.NotNil(t, result) assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "istioctl ztunnel-config failed") }) } diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go index ee80a32e..9a562f3d 100644 --- a/pkg/k8s/k8s.go +++ b/pkg/k8s/k8s.go @@ -3,179 +3,91 @@ package k8s import ( "context" _ "embed" - "encoding/json" "fmt" "maps" "math/rand" + "net/http" "os" "slices" "strings" + "time" - "k8s.io/client-go/tools/clientcmd" - - "github.com/kagent-dev/tools/pkg/logger" - "github.com/kagent-dev/tools/pkg/utils" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/tmc/langchaingo/llms" - "github.com/tmc/langchaingo/llms/openai" - v1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" -) - -// K8sClient wraps Kubernetes client operations -type K8sClient struct { - clientset kubernetes.Interface - config *rest.Config -} -// NewK8sClient creates a new Kubernetes client -func NewK8sClient() (*K8sClient, error) { - config, err := rest.InClusterConfig() - if err != nil { - // Fallback to kubeconfig - config, err = clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile) - if err != nil { - return nil, fmt.Errorf("failed to create k8s config: %v", err) - } - } - - clientset, err := kubernetes.NewForConfig(config) - if err != nil { - return nil, fmt.Errorf("failed to create k8s clientset: %v", err) - } - - return &K8sClient{ - clientset: clientset, - config: config, - }, nil -} + "github.com/kagent-dev/tools/internal/cache" + "github.com/kagent-dev/tools/internal/commands" + "github.com/kagent-dev/tools/internal/logger" + "github.com/kagent-dev/tools/internal/security" + "github.com/kagent-dev/tools/internal/telemetry" +) -// K8sTool struct to hold the client +// K8sTool struct to hold the LLM model type K8sTool struct { - client *K8sClient - llmModel llms.Model + kubeconfig string + llmModel llms.Model + tokenPassthrough bool // when true, require Bearer token and pass it to kubectl; when false, do not use token } -func NewK8sTool(llmModel llms.Model) (*K8sTool, error) { - client, err := NewK8sClient() - if err != nil { - return nil, err - } - - return &K8sTool{client: client, llmModel: llmModel}, nil +func NewK8sTool(llmModel llms.Model) *K8sTool { + return &K8sTool{llmModel: llmModel, tokenPassthrough: os.Getenv("TOKEN_PASSTHROUGH") == "true"} } -func (k *K8sTool) getPodsNative(ctx context.Context, name, namespace string, allNamespaces bool, output string) (*mcp.CallToolResult, error) { - var pods *corev1.PodList - var err error - if name != "" { - pod, err := k.client.clientset.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get pod: %v", err)), nil - } - pods = &corev1.PodList{Items: []corev1.Pod{*pod}} - } else if allNamespaces { - pods, err = k.client.clientset.CoreV1().Pods("").List(ctx, metav1.ListOptions{}) - } else { - pods, err = k.client.clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) - } - - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list pods: %v", err)), nil - } - - return formatResourceOutput(pods, output) +func NewK8sToolWithConfig(kubeconfig string, llmModel llms.Model) *K8sTool { + return &K8sTool{kubeconfig: kubeconfig, llmModel: llmModel, tokenPassthrough: os.Getenv("TOKEN_PASSTHROUGH") == "true"} } -func (k *K8sTool) getServicesNative(ctx context.Context, name, namespace string, allNamespaces bool, output string) (*mcp.CallToolResult, error) { - var services *corev1.ServiceList - var err error +// runKubectlCommandWithCacheInvalidation runs a kubectl command and invalidates cache if it's a modification operation +func (k *K8sTool) runKubectlCommandWithCacheInvalidation(ctx context.Context, headers http.Header, args ...string) (*mcp.CallToolResult, error) { + result, err := k.runKubectlCommand(ctx, headers, args...) - if name != "" { - service, err := k.client.clientset.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get service: %v", err)), nil + // If command succeeded and it's a modification command, invalidate cache + if err == nil && len(args) > 0 { + subcommand := args[0] + switch subcommand { + case "apply", "delete", "patch", "scale", "annotate", "label", "create", "run", "rollout": + cache.InvalidateKubernetesCache() } - services = &corev1.ServiceList{Items: []corev1.Service{*service}} - } else if allNamespaces { - services, err = k.client.clientset.CoreV1().Services("").List(ctx, metav1.ListOptions{}) - } else { - services, err = k.client.clientset.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{}) - } - - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list services: %v", err)), nil } - return formatResourceOutput(services, output) + return result, err } -func (k *K8sTool) getDeploymentsNative(ctx context.Context, name, namespace string, allNamespaces bool, output string) (*mcp.CallToolResult, error) { - var deployments *v1.DeploymentList - var err error - - if name != "" { - deployment, err := k.client.clientset.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get deployment: %v", err)), nil - } - deployments = &v1.DeploymentList{Items: []v1.Deployment{*deployment}} - } else if allNamespaces { - deployments, err = k.client.clientset.AppsV1().Deployments("").List(ctx, metav1.ListOptions{}) - } else { - deployments, err = k.client.clientset.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{}) - } +// Enhanced kubectl get +func (k *K8sTool) handleKubectlGetEnhanced(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resourceType := mcp.ParseString(request, "resource_type", "") + resourceName := mcp.ParseString(request, "resource_name", "") + namespace := mcp.ParseString(request, "namespace", "") + allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" + output := mcp.ParseString(request, "output", "wide") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list deployments: %v", err)), nil + if resourceType == "" { + return mcp.NewToolResultError("resource_type parameter is required"), nil } - return formatResourceOutput(deployments, output) -} - -func (k *K8sTool) getConfigMapsNative(ctx context.Context, name, namespace string, allNamespaces bool, output string) (*mcp.CallToolResult, error) { - var configMaps *corev1.ConfigMapList - var err error + args := []string{"get", resourceType} - if name != "" { - configMap, err := k.client.clientset.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get configmap: %v", err)), nil - } - configMaps = &corev1.ConfigMapList{Items: []corev1.ConfigMap{*configMap}} - } else if allNamespaces { - configMaps, err = k.client.clientset.CoreV1().ConfigMaps("").List(ctx, metav1.ListOptions{}) - } else { - configMaps, err = k.client.clientset.CoreV1().ConfigMaps(namespace).List(ctx, metav1.ListOptions{}) + if resourceName != "" { + args = append(args, resourceName) } - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list configmaps: %v", err)), nil + if allNamespaces { + args = append(args, "--all-namespaces") + } else if namespace != "" { + args = append(args, "-n", namespace) } - return formatResourceOutput(configMaps, output) -} - -func formatResourceOutput(data interface{}, output string) (*mcp.CallToolResult, error) { - if output == "json" || output == "" { - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal JSON: %v", err)), nil - } - return mcp.NewToolResultText(string(jsonData)), nil + if output != "" { + args = append(args, "-o", output) + } else { + args = append(args, "-o", "json") } - // For other output formats, convert to string representation - jsonData, _ := json.Marshal(data) - return mcp.NewToolResultText(string(jsonData)), nil + return k.runKubectlCommand(ctx, request.Header, args...) } -// Enhanced get pod logs with native client +// Get pod logs func (k *K8sTool) handleKubectlLogsEnhanced(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { podName := mcp.ParseString(request, "pod_name", "") namespace := mcp.ParseString(request, "namespace", "default") @@ -186,24 +98,20 @@ func (k *K8sTool) handleKubectlLogsEnhanced(ctx context.Context, request mcp.Cal return mcp.NewToolResultError("pod_name parameter is required"), nil } - lines := int64(tailLines) - logOptions := &corev1.PodLogOptions{ - TailLines: &lines, - } + args := []string{"logs", podName, "-n", namespace} if container != "" { - logOptions.Container = container + args = append(args, "-c", container) } - logs, err := k.client.clientset.CoreV1().Pods(namespace).GetLogs(podName, logOptions).DoRaw(ctx) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get pod logs: %v", err)), nil + if tailLines > 0 { + args = append(args, "--tail", fmt.Sprintf("%d", tailLines)) } - return mcp.NewToolResultText(string(logs)), nil + return k.runKubectlCommand(ctx, request.Header, args...) } -// Scale deployment using native client +// Scale deployment func (k *K8sTool) handleScaleDeployment(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { deploymentName := mcp.ParseString(request, "name", "") namespace := mcp.ParseString(request, "namespace", "default") @@ -213,24 +121,44 @@ func (k *K8sTool) handleScaleDeployment(ctx context.Context, request mcp.CallToo return mcp.NewToolResultError("name parameter is required"), nil } - deployment, err := k.client.clientset.AppsV1().Deployments(namespace).Get(ctx, deploymentName, metav1.GetOptions{}) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get deployment: %v", err)), nil + args := []string{"scale", "deployment", deploymentName, "--replicas", fmt.Sprintf("%d", replicas), "-n", namespace} + + return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...) +} + +// Patch resource +func (k *K8sTool) handlePatchResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resourceType := mcp.ParseString(request, "resource_type", "") + resourceName := mcp.ParseString(request, "resource_name", "") + patch := mcp.ParseString(request, "patch", "") + namespace := mcp.ParseString(request, "namespace", "default") + + if resourceType == "" || resourceName == "" || patch == "" { + return mcp.NewToolResultError("resource_type, resource_name, and patch parameters are required"), nil } - replicasInt32 := int32(replicas) - deployment.Spec.Replicas = &replicasInt32 + // Validate resource name for security + if err := security.ValidateK8sResourceName(resourceName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil + } - _, err = k.client.clientset.AppsV1().Deployments(namespace).Update(ctx, deployment, metav1.UpdateOptions{}) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to scale deployment: %v", err)), nil + // Validate namespace for security + if err := security.ValidateNamespace(namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil + } + + // Validate patch content as JSON/YAML + if err := security.ValidateYAMLContent(patch); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid patch content: %v", err)), nil } - return mcp.NewToolResultText(fmt.Sprintf("Deployment %s scaled to %d replicas", deploymentName, replicas)), nil + args := []string{"patch", resourceType, resourceName, "-p", patch, "-n", namespace} + + return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...) } -// Patch resource using native client -func (k *K8sTool) handlePatchResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// Patch resource status +func (k *K8sTool) handlePatchStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { resourceType := mcp.ParseString(request, "resource_type", "") resourceName := mcp.ParseString(request, "resource_name", "") patch := mcp.ParseString(request, "patch", "") @@ -240,12 +168,34 @@ func (k *K8sTool) handlePatchResource(ctx context.Context, request mcp.CallToolR return mcp.NewToolResultError("resource_type, resource_name, and patch parameters are required"), nil } - _, err := k.client.clientset.CoreV1().Pods(namespace).Patch(ctx, resourceName, types.StrategicMergePatchType, []byte(patch), metav1.PatchOptions{}) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to patch resource: %v", err)), nil + // Validate resource name for security + if err := security.ValidateK8sResourceName(resourceName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid resource name: %v", err)), nil } - return mcp.NewToolResultText(fmt.Sprintf("Resource %s/%s patched successfully", resourceType, resourceName)), nil + // Validate namespace for security + if err := security.ValidateNamespace(namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil + } + + // Validate patch content as JSON/YAML + if err := security.ValidateYAMLContent(patch); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid patch content: %v", err)), nil + } + + args := []string{ + "patch", + resourceType, + resourceName, + "--subresource=status", + "--type=merge", + "-p", + patch, + "-n", + namespace, + } + + return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...) } // Apply manifest from content @@ -256,24 +206,44 @@ func (k *K8sTool) handleApplyManifest(ctx context.Context, request mcp.CallToolR return mcp.NewToolResultError("manifest parameter is required"), nil } - // This handler still uses kubectl apply, which is not ideal for native Go implementation. - // For a pure Go approach, we would parse the manifest and use the appropriate client to create/update resources. - // This is a complex task and for now we will keep the kubectl fallback. - tmpFile, err := os.CreateTemp("", "manifest-*.yaml") + // Validate YAML content for security + if err := security.ValidateYAMLContent(manifest); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid manifest content: %v", err)), nil + } + + // Create temporary file with secure permissions + tmpFile, err := os.CreateTemp("", "k8s-manifest-*.yaml") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to create temp file: %v", err)), nil } - defer os.Remove(tmpFile.Name()) + // Ensure file is removed regardless of execution path + defer func() { + if removeErr := os.Remove(tmpFile.Name()); removeErr != nil { + logger.Get().Error("Failed to remove temporary file", "error", removeErr, "file", tmpFile.Name()) + } + }() + + // Set secure file permissions (readable/writable by owner only) + if err := os.Chmod(tmpFile.Name(), 0600); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to set file permissions: %v", err)), nil + } + + // Write manifest content to temporary file if _, err := tmpFile.WriteString(manifest); err != nil { + tmpFile.Close() return mcp.NewToolResultError(fmt.Sprintf("Failed to write to temp file: %v", err)), nil } - tmpFile.Close() - return k.runKubectlCommand(ctx, []string{"apply", "-f", tmpFile.Name()}) + // Close the file before passing to kubectl + if err := tmpFile.Close(); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to close temp file: %v", err)), nil + } + + return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, "apply", "-f", tmpFile.Name()) } -// Delete resource using native client +// Delete resource func (k *K8sTool) handleDeleteResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { resourceType := mcp.ParseString(request, "resource_type", "") resourceName := mcp.ParseString(request, "resource_name", "") @@ -283,30 +253,9 @@ func (k *K8sTool) handleDeleteResource(ctx context.Context, request mcp.CallTool return mcp.NewToolResultError("resource_type and resource_name parameters are required"), nil } - deletePolicy := metav1.DeletePropagationForeground - deleteOptions := metav1.DeleteOptions{ - PropagationPolicy: &deletePolicy, - } + args := []string{"delete", resourceType, resourceName, "-n", namespace} - var err error - switch resourceType { - case "pods", "pod": - err = k.client.clientset.CoreV1().Pods(namespace).Delete(ctx, resourceName, deleteOptions) - case "services", "service", "svc": - err = k.client.clientset.CoreV1().Services(namespace).Delete(ctx, resourceName, deleteOptions) - case "deployments", "deployment", "deploy": - err = k.client.clientset.AppsV1().Deployments(namespace).Delete(ctx, resourceName, deleteOptions) - case "configmaps", "configmap", "cm": - err = k.client.clientset.CoreV1().ConfigMaps(namespace).Delete(ctx, resourceName, deleteOptions) - default: - return mcp.NewToolResultError(fmt.Sprintf("Unsupported resource type for deletion: %s", resourceType)), nil - } - - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to delete resource: %v", err)), nil - } - - return mcp.NewToolResultText(fmt.Sprintf("Resource %s/%s deleted successfully", resourceType, resourceName)), nil + return k.runKubectlCommandWithCacheInvalidation(ctx, request.Header, args...) } // Check service connectivity @@ -318,36 +267,43 @@ func (k *K8sTool) handleCheckServiceConnectivity(ctx context.Context, request mc return mcp.NewToolResultError("service_name parameter is required"), nil } - // This is a complex operation to perform natively, involving creating a temporary pod. - // We'll keep the kubectl approach for this tool for now. + // Create a temporary curl pod for connectivity check podName := fmt.Sprintf("curl-test-%d", rand.Intn(10000)) - defer k.runKubectlCommand(ctx, []string{"delete", "pod", podName, "-n", namespace, "--ignore-not-found"}) + defer func() { + _, _ = k.runKubectlCommand(ctx, request.Header, "delete", "pod", podName, "-n", namespace, "--ignore-not-found") + }() - _, err := k.runKubectlCommand(ctx, []string{"run", podName, "--image=curlimages/curl", "-n", namespace, "--restart=Never", "--", "sleep", "3600"}) + // Create the curl pod + _, err := k.runKubectlCommand(ctx, request.Header, "run", podName, "--image=curlimages/curl", "-n", namespace, "--restart=Never", "--", "sleep", "3600") if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to create curl pod: %v", err)), nil } - _, err = k.runKubectlCommand(ctx, []string{"wait", "--for=condition=ready", "pod/" + podName, "-n", namespace, "--timeout=60s"}) + // Wait for pod to be ready + _, err = k.runKubectlCommandWithTimeout(ctx, request.Header, 60*time.Second, "wait", "--for=condition=ready", "pod/"+podName, "-n", namespace) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to wait for curl pod: %v", err)), nil } - return k.runKubectlCommand(ctx, []string{"exec", podName, "-n", namespace, "--", "curl", "-s", serviceName}) + // Execute kubectl command + return k.runKubectlCommand(ctx, request.Header, "exec", podName, "-n", namespace, "--", "curl", "-s", serviceName) } -// Get cluster events using native client +// Get cluster events func (k *K8sTool) handleGetEvents(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { namespace := mcp.ParseString(request, "namespace", "") - events, err := k.client.clientset.CoreV1().Events(namespace).List(ctx, metav1.ListOptions{}) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get events: %v", err)), nil + args := []string{"get", "events", "-o", "json"} + if namespace != "" { + args = append(args, "-n", namespace) + } else { + args = append(args, "--all-namespaces") } - return formatResourceOutput(events, "json") + + return k.runKubectlCommand(ctx, request.Header, args...) } -// Execute command in pod using native client +// Execute command in pod func (k *K8sTool) handleExecCommand(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { podName := mcp.ParseString(request, "pod_name", "") namespace := mcp.ParseString(request, "namespace", "default") @@ -357,46 +313,32 @@ func (k *K8sTool) handleExecCommand(ctx context.Context, request mcp.CallToolReq return mcp.NewToolResultError("pod_name and command parameters are required"), nil } - // This handler uses kubectl exec. - return k.runKubectlCommand(ctx, []string{"exec", podName, "-n", namespace, "--", command}) -} - -// Fallback to kubectl command for get operations -func (k *K8sTool) handleKubectlGetTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - resourceType := mcp.ParseString(request, "resource_type", "") - resourceName := mcp.ParseString(request, "resource_name", "") - namespace := mcp.ParseString(request, "namespace", "") - output := mcp.ParseString(request, "output", "wide") - allNamespaces := mcp.ParseBoolean(request, "all_namespaces", false) - - if resourceType == "" { - return mcp.NewToolResultError("resource_type parameter is required"), nil + // Validate pod name for security + if err := security.ValidateK8sResourceName(podName); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid pod name: %v", err)), nil } - args := []string{"get", resourceType} - - if resourceName != "" { - args = append(args, resourceName) + // Validate namespace for security + if err := security.ValidateNamespace(namespace); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid namespace: %v", err)), nil } - if namespace != "" { - args = append(args, "-n", namespace) + // Validate command input for security + if err := security.ValidateCommandInput(command); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid command: %v", err)), nil } - if allNamespaces { - args = append(args, "-A") - } + args := []string{"exec", podName, "-n", namespace, "--", command} - if output != "" { - args = append(args, "-o", output) - } else { - args = append(args, "-o", "json") - } + return k.runKubectlCommand(ctx, request.Header, args...) +} - return k.runKubectlCommand(ctx, args) +// Get available API resources +func (k *K8sTool) handleGetAvailableAPIResources(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.runKubectlCommand(ctx, request.Header, "api-resources") } -// Fallback to kubectl command for describe operations +// Kubectl describe tool func (k *K8sTool) handleKubectlDescribeTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { resourceType := mcp.ParseString(request, "resource_type", "") resourceName := mcp.ParseString(request, "resource_name", "") @@ -411,32 +353,10 @@ func (k *K8sTool) handleKubectlDescribeTool(ctx context.Context, request mcp.Cal args = append(args, "-n", namespace) } - return k.runKubectlCommand(ctx, args) -} - -func (k *K8sTool) runKubectlCommand(ctx context.Context, args []string) (*mcp.CallToolResult, error) { - result, err := utils.RunCommandWithContext(ctx, "kubectl", args) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - return mcp.NewToolResultText(result), nil -} - -func (k *K8sTool) handleGetAvailableAPIResources(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - serverResources, err := k.client.clientset.Discovery().ServerPreferredResources() - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get available API resources: %v", err)), nil - } - - // We can format this into a more readable string or return the JSON - jsonData, err := json.MarshalIndent(serverResources, "", " ") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal JSON: %v", err)), nil - } - - return mcp.NewToolResultText(string(jsonData)), nil + return k.runKubectlCommand(ctx, request.Header, args...) } +// Rollout operations func (k *K8sTool) handleRollout(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { action := mcp.ParseString(request, "action", "") resourceType := mcp.ParseString(request, "resource_type", "") @@ -452,13 +372,15 @@ func (k *K8sTool) handleRollout(ctx context.Context, request mcp.CallToolRequest args = append(args, "-n", namespace) } - return k.runKubectlCommand(ctx, args) + return k.runKubectlCommand(ctx, request.Header, args...) } +// Get cluster configuration func (k *K8sTool) handleGetClusterConfiguration(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return k.runKubectlCommand(ctx, []string{"config", "view"}) + return k.runKubectlCommand(ctx, request.Header, "config", "view", "-o", "json") } +// Remove annotation func (k *K8sTool) handleRemoveAnnotation(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { resourceType := mcp.ParseString(request, "resource_type", "") resourceName := mcp.ParseString(request, "resource_name", "") @@ -474,9 +396,10 @@ func (k *K8sTool) handleRemoveAnnotation(ctx context.Context, request mcp.CallTo args = append(args, "-n", namespace) } - return k.runKubectlCommand(ctx, args) + return k.runKubectlCommand(ctx, request.Header, args...) } +// Remove label func (k *K8sTool) handleRemoveLabel(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { resourceType := mcp.ParseString(request, "resource_type", "") resourceName := mcp.ParseString(request, "resource_name", "") @@ -492,9 +415,10 @@ func (k *K8sTool) handleRemoveLabel(ctx context.Context, request mcp.CallToolReq args = append(args, "-n", namespace) } - return k.runKubectlCommand(ctx, args) + return k.runKubectlCommand(ctx, request.Header, args...) } +// Annotate resource func (k *K8sTool) handleAnnotateResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { resourceType := mcp.ParseString(request, "resource_type", "") resourceName := mcp.ParseString(request, "resource_name", "") @@ -512,9 +436,10 @@ func (k *K8sTool) handleAnnotateResource(ctx context.Context, request mcp.CallTo args = append(args, "-n", namespace) } - return k.runKubectlCommand(ctx, args) + return k.runKubectlCommand(ctx, request.Header, args...) } +// Label resource func (k *K8sTool) handleLabelResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { resourceType := mcp.ParseString(request, "resource_type", "") resourceName := mcp.ParseString(request, "resource_name", "") @@ -532,9 +457,10 @@ func (k *K8sTool) handleLabelResource(ctx context.Context, request mcp.CallToolR args = append(args, "-n", namespace) } - return k.runKubectlCommand(ctx, args) + return k.runKubectlCommand(ctx, request.Header, args...) } +// Create resource from URL func (k *K8sTool) handleCreateResourceFromURL(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { url := mcp.ParseString(request, "url", "") namespace := mcp.ParseString(request, "namespace", "") @@ -548,9 +474,10 @@ func (k *K8sTool) handleCreateResourceFromURL(ctx context.Context, request mcp.C args = append(args, "-n", namespace) } - return k.runKubectlCommand(ctx, args) + return k.runKubectlCommand(ctx, request.Header, args...) } +// Resource generation embeddings var ( //go:embed resources/istio/peer_auth.md istioAuthPolicy string @@ -594,6 +521,7 @@ var ( resourceTypes = maps.Keys(resourceMap) ) +// Generate resource using LLM func (k *K8sTool) handleGenerateResource(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { resourceType := mcp.ParseString(request, "resource_type", "") resourceDescription := mcp.ParseString(request, "resource_description", "") @@ -620,7 +548,6 @@ func (k *K8sTool) handleGenerateResource(ctx context.Context, request mcp.CallTo llms.TextContent{Text: systemPrompt}, }, }, - { Role: llms.ChatMessageTypeHuman, Parts: []llms.ContentPart{ @@ -639,177 +566,135 @@ func (k *K8sTool) handleGenerateResource(ctx context.Context, request mcp.CallTo return mcp.NewToolResultError("empty response from model"), nil } c1 := choices[0] - return mcp.NewToolResultText(c1.Content), nil + responseText := c1.Content + + return mcp.NewToolResultText(responseText), nil } -func RegisterK8sTools(s *server.MCPServer) { - var llm llms.Model - if openAiClient, err := openai.New(); err == nil { - llm = openAiClient - } else { - logger.Get().Error(err, "Failed to initialize OpenAI LLM, k8s_generate_resource tool will not be available") +func (k *K8sTool) handleWaitForCondition(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resourceType := mcp.ParseString(request, "resource_type", "") + resourceName := mcp.ParseString(request, "resource_name", "") + condition := mcp.ParseString(request, "condition", "") + namespace := mcp.ParseString(request, "namespace", "default") + timeoutSeconds := mcp.ParseInt(request, "timeout_seconds", 60) + + if resourceType == "" || resourceName == "" || condition == "" { + return mcp.NewToolResultError("resource_type, resource_name, and condition are required"), nil + } + if timeoutSeconds <= 0 { + return mcp.NewToolResultError("timeout_seconds must be greater than zero"), nil + } + + return k.runKubectlCommand(ctx, request.Header, + "wait", resourceType+"/"+resourceName, + "--for=condition="+condition, + "-n", namespace, + "--timeout="+fmt.Sprintf("%ds", timeoutSeconds), + ) +} + +// extractBearerToken extracts the Bearer token from the Authorization header +func extractBearerToken(headers http.Header) string { + if auth := headers.Get("Authorization"); auth != "" { + if strings.HasPrefix(auth, "Bearer ") { + return strings.TrimPrefix(auth, "Bearer ") + } + } + return "" +} + +// tokenForKubectl returns the token to pass to kubectl and an error if passthrough is true but token is missing. +func (k *K8sTool) tokenForKubectl(headers http.Header) (string, error) { + token := extractBearerToken(headers) + if k.tokenPassthrough && token == "" { + return "", fmt.Errorf("Bearer token required when TOKEN_PASSTHROUGH is true") + } + if k.tokenPassthrough { + return token, nil + } + return "", nil // do not use token when passthrough is false +} + +// runKubectlCommand is a helper function to execute kubectl commands +func (k *K8sTool) runKubectlCommand(ctx context.Context, headers http.Header, args ...string) (*mcp.CallToolResult, error) { + token, err := k.tokenForKubectl(headers) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + builder := commands.NewCommandBuilder("kubectl"). + WithArgs(args...). + WithKubeconfig(k.kubeconfig) + if token != "" { + builder = builder.WithToken(token) } + output, err := builder.Execute(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultText(output), nil +} - k8sTool, err := NewK8sTool(llm) +// runKubectlCommandWithTimeout is a helper function to execute kubectl commands with a timeout +func (k *K8sTool) runKubectlCommandWithTimeout(ctx context.Context, headers http.Header, timeout time.Duration, args ...string) (*mcp.CallToolResult, error) { + token, err := k.tokenForKubectl(headers) if err != nil { - // Log the error and proceed without native tool implementations - logger.Get().Info("Failed to initialize Kubernetes client, falling back to kubectl commands", - "level", "warn", "error", err.Error()) - // Here you could register the pure-kubectl versions of the tools as a fallback - return + return mcp.NewToolResultError(err.Error()), nil + } + builder := commands.NewCommandBuilder("kubectl"). + WithArgs(args...). + WithKubeconfig(k.kubeconfig). + WithTimeout(timeout) + if token != "" { + builder = builder.WithToken(token) } + output, err := builder.Execute(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultText(output), nil +} + +// RegisterK8sTools registers all k8s tools with the MCP server +func RegisterTools(s *server.MCPServer, llm llms.Model, kubeconfig string, readOnly bool) { + k8sTool := NewK8sToolWithConfig(kubeconfig, llm) + + // Read-only tools - always registered s.AddTool(mcp.NewTool("k8s_get_resources", - mcp.WithDescription("Get Kubernetes resources using kubectl with enhanced native client support"), + mcp.WithDescription("Get Kubernetes resources using kubectl"), mcp.WithString("resource_type", mcp.Description("Type of resource (pod, service, deployment, etc.)"), mcp.Required()), mcp.WithString("resource_name", mcp.Description("Name of specific resource (optional)")), mcp.WithString("namespace", mcp.Description("Namespace to query (optional)")), - mcp.WithBoolean("all_namespaces", mcp.Description("Query all namespaces (true/false)")), - mcp.WithString("output", mcp.Description("Output format (json, yaml, wide, etc.)")), - ), k8sTool.handleKubectlGetTool) + mcp.WithString("all_namespaces", mcp.Description("Query all namespaces (true/false)")), + mcp.WithString("output", mcp.Description("Output format (json, yaml, wide)"), mcp.DefaultString("wide")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_resources", k8sTool.handleKubectlGetEnhanced))) s.AddTool(mcp.NewTool("k8s_get_pod_logs", - mcp.WithDescription("Get logs from a Kubernetes pod with enhanced native client support"), + mcp.WithDescription("Get logs from a Kubernetes pod"), mcp.WithString("pod_name", mcp.Description("Name of the pod"), mcp.Required()), mcp.WithString("namespace", mcp.Description("Namespace of the pod (default: default)")), mcp.WithString("container", mcp.Description("Container name (for multi-container pods)")), mcp.WithNumber("tail_lines", mcp.Description("Number of lines to show from the end (default: 50)")), - ), k8sTool.handleKubectlLogsEnhanced) - - s.AddTool(mcp.NewTool("k8s_scale", - mcp.WithDescription("Scale a Kubernetes deployment using native client"), - mcp.WithString("name", mcp.Description("Name of the deployment"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the deployment (default: default)")), - mcp.WithNumber("replicas", mcp.Description("Number of replicas"), mcp.Required()), - ), k8sTool.handleScaleDeployment) - - s.AddTool(mcp.NewTool("k8s_patch_resource", - mcp.WithDescription("Patch a Kubernetes resource using strategic merge patch"), - mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, service, etc.)"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), - mcp.WithString("patch", mcp.Description("JSON patch to apply"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), - ), k8sTool.handlePatchResource) - - s.AddTool(mcp.NewTool("k8s_apply_manifest", - mcp.WithDescription("Apply a YAML manifest to the Kubernetes cluster"), - mcp.WithString("manifest", mcp.Description("YAML manifest content"), mcp.Required()), - ), k8sTool.handleApplyManifest) - - s.AddTool(mcp.NewTool("k8s_delete_resource", - mcp.WithDescription("Delete a Kubernetes resource using native client"), - mcp.WithString("resource_type", mcp.Description("Type of resource (pod, service, deployment, etc.)"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), - ), k8sTool.handleDeleteResource) - - s.AddTool(mcp.NewTool("k8s_check_service_connectivity", - mcp.WithDescription("Check connectivity to a service using a temporary curl pod"), - mcp.WithString("service_name", mcp.Description("Service name to test (e.g., my-service.my-namespace.svc.cluster.local:80)"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace to run the check from (default: default)")), - ), k8sTool.handleCheckServiceConnectivity) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_pod_logs", k8sTool.handleKubectlLogsEnhanced))) s.AddTool(mcp.NewTool("k8s_get_events", - mcp.WithDescription("Get Kubernetes cluster events using native client"), - mcp.WithString("namespace", mcp.Description("Namespace to query events from (optional, default: all namespaces)")), - ), k8sTool.handleGetEvents) - - s.AddTool(mcp.NewTool("k8s_execute_command", - mcp.WithDescription("Execute a command inside a Kubernetes pod"), - mcp.WithString("pod_name", mcp.Description("Name of the pod"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("Namespace of the pod (default: default)")), - mcp.WithString("command", mcp.Description("Command to execute"), mcp.Required()), - ), k8sTool.handleExecCommand) + mcp.WithDescription("Get events from a Kubernetes namespace"), + mcp.WithString("namespace", mcp.Description("Namespace to get events from (default: default)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_events", k8sTool.handleGetEvents))) s.AddTool(mcp.NewTool("k8s_get_available_api_resources", - mcp.WithDescription("Get all available API resources from the Kubernetes cluster"), - ), k8sTool.handleGetAvailableAPIResources) + mcp.WithDescription("Get available Kubernetes API resources"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_available_api_resources", k8sTool.handleGetAvailableAPIResources))) s.AddTool(mcp.NewTool("k8s_get_cluster_configuration", - mcp.WithDescription("Get the current kubectl cluster configuration"), - ), k8sTool.handleGetClusterConfiguration) - - s.AddTool(mcp.NewTool("k8s_rollout", - mcp.WithDescription("Perform rollout operations on Kubernetes resources (history, pause, restart, resume, status, undo)"), - mcp.WithString("action", mcp.Description("The rollout action to perform"), mcp.Required()), - mcp.WithString("resource_type", mcp.Description("The type of resource to rollout (e.g., deployment)"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("The name of the resource to rollout"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the resource")), - ), k8sTool.handleRollout) - - s.AddTool(mcp.NewTool("k8s_label_resource", - mcp.WithDescription("Add or update labels on a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), - mcp.WithString("labels", mcp.Description("Space-separated key=value pairs for labels"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the resource")), - ), k8sTool.handleLabelResource) - - s.AddTool(mcp.NewTool("k8s_annotate_resource", - mcp.WithDescription("Add or update annotations on a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), - mcp.WithString("annotations", mcp.Description("Space-separated key=value pairs for annotations"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the resource")), - ), k8sTool.handleAnnotateResource) - - s.AddTool(mcp.NewTool("k8s_remove_annotation", - mcp.WithDescription("Remove an annotation from a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), - mcp.WithString("annotation_key", mcp.Description("The key of the annotation to remove"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the resource")), - ), k8sTool.handleRemoveAnnotation) - - s.AddTool(mcp.NewTool("k8s_remove_label", - mcp.WithDescription("Remove a label from a Kubernetes resource"), - mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), - mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), - mcp.WithString("label_key", mcp.Description("The key of the label to remove"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace of the resource")), - ), k8sTool.handleRemoveLabel) - - s.AddTool(mcp.NewTool("k8s_create_resource", - mcp.WithDescription("Create a Kubernetes resource from YAML content"), - mcp.WithString("yaml_content", mcp.Description("YAML content of the resource"), mcp.Required()), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - yamlContent := mcp.ParseString(request, "yaml_content", "") - - if yamlContent == "" { - return mcp.NewToolResultError("yaml_content is required"), nil - } - - // Create temporary file - tmpFile, err := os.CreateTemp("", "k8s-resource-*.yaml") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to create temp file: %v", err)), nil - } - defer os.Remove(tmpFile.Name()) - - if _, err := tmpFile.WriteString(yamlContent); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to write to temp file: %v", err)), nil - } - tmpFile.Close() - - result, err := utils.RunCommandWithContext(ctx, "kubectl", []string{"create", "-f", tmpFile.Name()}) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Create command failed: %v", err)), nil - } - - return mcp.NewToolResultText(result), nil - }) - - s.AddTool(mcp.NewTool("k8s_create_resource_from_url", - mcp.WithDescription("Create a Kubernetes resource from a URL pointing to a YAML manifest"), - mcp.WithString("url", mcp.Description("The URL of the manifest"), mcp.Required()), - mcp.WithString("namespace", mcp.Description("The namespace to create the resource in")), - ), k8sTool.handleCreateResourceFromURL) + mcp.WithDescription("Get cluster configuration details"), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_cluster_configuration", k8sTool.handleGetClusterConfiguration))) s.AddTool(mcp.NewTool("k8s_get_resource_yaml", mcp.WithDescription("Get the YAML representation of a Kubernetes resource"), mcp.WithString("resource_type", mcp.Description("Type of resource"), mcp.Required()), mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), mcp.WithString("namespace", mcp.Description("Namespace of the resource (optional)")), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_resource_yaml", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { resourceType := mcp.ParseString(request, "resource_type", "") resourceName := mcp.ParseString(request, "resource_name", "") namespace := mcp.ParseString(request, "namespace", "") @@ -823,24 +708,161 @@ func RegisterK8sTools(s *server.MCPServer) { args = append(args, "-n", namespace) } - result, err := utils.RunCommandWithContext(ctx, "kubectl", args) + result, err := k8sTool.runKubectlCommand(ctx, request.Header, args...) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Get YAML command failed: %v", err)), nil } - return mcp.NewToolResultText(result), nil - }) + return result, nil + }))) s.AddTool(mcp.NewTool("k8s_describe_resource", mcp.WithDescription("Describe a Kubernetes resource in detail"), mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, service, pod, node, etc.)"), mcp.Required()), mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), mcp.WithString("namespace", mcp.Description("Namespace of the resource (optional)")), - ), k8sTool.handleKubectlDescribeTool) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_describe_resource", k8sTool.handleKubectlDescribeTool))) s.AddTool(mcp.NewTool("k8s_generate_resource", mcp.WithDescription("Generate a Kubernetes resource YAML from a description"), mcp.WithString("resource_description", mcp.Description("Detailed description of the resource to generate"), mcp.Required()), mcp.WithString("resource_type", mcp.Description(fmt.Sprintf("Type of resource to generate (%s)", strings.Join(slices.Collect(resourceTypes), ", "))), mcp.Required()), - ), k8sTool.handleGenerateResource) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_generate_resource", k8sTool.handleGenerateResource))) + + s.AddTool(mcp.NewTool("k8s_wait_for_condition", + mcp.WithDescription("Wait until a Kubernetes resource reaches a specific condition. Uses kubectl wait under the hood and blocks until the condition is met or the timeout expires. Avoids polling loops and saves LLM turns."), + mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, pod, job, etc.)"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), + mcp.WithString("condition", mcp.Description("Condition to wait for (Available, Ready, Complete, etc.)"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), + mcp.WithNumber("timeout_seconds", mcp.Description("Maximum time to wait in seconds (default: 60)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_wait_for_condition", k8sTool.handleWaitForCondition))) + + // Write tools - only registered when write operations are enabled + if !readOnly { + s.AddTool(mcp.NewTool("k8s_scale", + mcp.WithDescription("Scale a Kubernetes deployment"), + mcp.WithString("name", mcp.Description("Name of the deployment"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace of the deployment (default: default)")), + mcp.WithNumber("replicas", mcp.Description("Number of replicas"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_scale", k8sTool.handleScaleDeployment))) + + s.AddTool(mcp.NewTool("k8s_patch_resource", + mcp.WithDescription("Patch a Kubernetes resource using strategic merge patch"), + mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, service, etc.)"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), + mcp.WithString("patch", mcp.Description("JSON patch to apply"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_patch_resource", k8sTool.handlePatchResource))) + + s.AddTool(mcp.NewTool("k8s_patch_status", + mcp.WithDescription("Patch the status of a Kubernetes resource"), + mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, service, etc.)"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), + mcp.WithString("patch", mcp.Description("JSON/YAML status patch"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_patch_status", k8sTool.handlePatchStatus))) + + s.AddTool(mcp.NewTool("k8s_apply_manifest", + mcp.WithDescription("Apply a YAML manifest to the Kubernetes cluster"), + mcp.WithString("manifest", mcp.Description("YAML manifest content"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_apply_manifest", k8sTool.handleApplyManifest))) + + s.AddTool(mcp.NewTool("k8s_delete_resource", + mcp.WithDescription("Delete a Kubernetes resource"), + mcp.WithString("resource_type", mcp.Description("Type of resource (pod, service, deployment, etc.)"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_delete_resource", k8sTool.handleDeleteResource))) + + s.AddTool(mcp.NewTool("k8s_check_service_connectivity", + mcp.WithDescription("Check connectivity to a service using a temporary curl pod"), + mcp.WithString("service_name", mcp.Description("Service name to test (e.g., my-service.my-namespace.svc.cluster.local:80)"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace to run the check from (default: default)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_check_service_connectivity", k8sTool.handleCheckServiceConnectivity))) + + s.AddTool(mcp.NewTool("k8s_execute_command", + mcp.WithDescription("Execute a command in a Kubernetes pod"), + mcp.WithString("pod_name", mcp.Description("Name of the pod to execute in"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace of the pod (default: default)")), + mcp.WithString("container", mcp.Description("Container name (for multi-container pods)")), + mcp.WithString("command", mcp.Description("Command to execute"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_execute_command", k8sTool.handleExecCommand))) + + s.AddTool(mcp.NewTool("k8s_rollout", + mcp.WithDescription("Perform rollout operations on Kubernetes resources (history, pause, restart, resume, status, undo)"), + mcp.WithString("action", mcp.Description("The rollout action to perform"), mcp.Required()), + mcp.WithString("resource_type", mcp.Description("The type of resource to rollout (e.g., deployment)"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("The name of the resource to rollout"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace of the resource")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_rollout", k8sTool.handleRollout))) + + s.AddTool(mcp.NewTool("k8s_label_resource", + mcp.WithDescription("Add or update labels on a Kubernetes resource"), + mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), + mcp.WithString("labels", mcp.Description("Space-separated key=value pairs for labels"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace of the resource")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_label_resource", k8sTool.handleLabelResource))) + + s.AddTool(mcp.NewTool("k8s_annotate_resource", + mcp.WithDescription("Add or update annotations on a Kubernetes resource"), + mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), + mcp.WithString("annotations", mcp.Description("Space-separated key=value pairs for annotations"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace of the resource")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_annotate_resource", k8sTool.handleAnnotateResource))) + + s.AddTool(mcp.NewTool("k8s_remove_annotation", + mcp.WithDescription("Remove an annotation from a Kubernetes resource"), + mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), + mcp.WithString("annotation_key", mcp.Description("The key of the annotation to remove"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace of the resource")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_remove_annotation", k8sTool.handleRemoveAnnotation))) + + s.AddTool(mcp.NewTool("k8s_remove_label", + mcp.WithDescription("Remove a label from a Kubernetes resource"), + mcp.WithString("resource_type", mcp.Description("The type of resource"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("The name of the resource"), mcp.Required()), + mcp.WithString("label_key", mcp.Description("The key of the label to remove"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace of the resource")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_remove_label", k8sTool.handleRemoveLabel))) + + s.AddTool(mcp.NewTool("k8s_create_resource", + mcp.WithDescription("Create a Kubernetes resource from YAML content"), + mcp.WithString("yaml_content", mcp.Description("YAML content of the resource"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_create_resource", func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + yamlContent := mcp.ParseString(request, "yaml_content", "") + + if yamlContent == "" { + return mcp.NewToolResultError("yaml_content is required"), nil + } + + // Create temporary file + tmpFile, err := os.CreateTemp("", "k8s-resource-*.yaml") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to create temp file: %v", err)), nil + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(yamlContent); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to write to temp file: %v", err)), nil + } + tmpFile.Close() + + result, err := k8sTool.runKubectlCommand(ctx, request.Header, "create", "-f", tmpFile.Name()) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Create command failed: %v", err)), nil + } + + return result, nil + }))) + + s.AddTool(mcp.NewTool("k8s_create_resource_from_url", + mcp.WithDescription("Create a Kubernetes resource from a URL pointing to a YAML manifest"), + mcp.WithString("url", mcp.Description("The URL of the manifest"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("The namespace to create the resource in")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_create_resource_from_url", k8sTool.handleCreateResourceFromURL))) + } } diff --git a/pkg/k8s/k8s_test.go b/pkg/k8s/k8s_test.go index dae3215d..0b35c1c1 100644 --- a/pkg/k8s/k8s_test.go +++ b/pkg/k8s/k8s_test.go @@ -2,43 +2,31 @@ package k8s import ( "context" + "net/http" "testing" - "github.com/kagent-dev/tools/pkg/utils" + "github.com/kagent-dev/tools/internal/cmd" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tmc/langchaingo/llms" - v1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/kubernetes/fake" - "k8s.io/client-go/rest" - k8stesting "k8s.io/client-go/testing" - "k8s.io/utils/ptr" ) -// Helper function to create a test K8sTool with fake client -func newTestK8sTool(clientset kubernetes.Interface) *K8sTool { - return &K8sTool{ - client: &K8sClient{ - clientset: clientset, - config: &rest.Config{}, - }, - } +// Helper function to create a test K8sTool +func newTestK8sTool() *K8sTool { + return NewK8sTool(nil) } -// Helper function to create a test K8sTool with fake client and mock LLM -func newTestK8sToolWithLLM(clientset kubernetes.Interface, llm llms.Model) *K8sTool { - return &K8sTool{ - client: &K8sClient{ - clientset: clientset, - config: &rest.Config{}, - }, - llmModel: llm, - } +// newTestK8sToolWithPassthrough creates a K8sTool with token passthrough set for testing. +func newTestK8sToolWithPassthrough(passthrough bool) *K8sTool { + t := NewK8sTool(nil) + t.tokenPassthrough = passthrough + return t +} + +// Helper function to create a test K8sTool with mock LLM +func newTestK8sToolWithLLM(llm llms.Model) *K8sTool { + return NewK8sTool(llm) } // Helper function to extract text content from MCP result @@ -52,55 +40,33 @@ func getResultText(result *mcp.CallToolResult) string { return "" } -func TestNewK8sClient(t *testing.T) { - // Test that NewK8sClient handles errors gracefully - // This will likely fail in test environment without kubeconfig, which is expected - _, err := NewK8sClient() - // We don't fail the test if client creation fails, as it's expected in test env - if err != nil { - t.Logf("NewK8sClient failed as expected in test environment: %v", err) - } +// Helper function to create an http.Header with Bearer token authorization +func headerWithBearerToken(token string) http.Header { + h := http.Header{} + h.Set("Authorization", "Bearer "+token) + return h } -func TestFormatResourceOutput(t *testing.T) { - testData := map[string]interface{}{ - "test": "data", - "number": 42, - } - - // Test JSON output format - result, err := formatResourceOutput(testData, "json") - if err != nil { - t.Fatalf("formatResourceOutput failed: %v", err) - } - - if len(result.Content) == 0 { - t.Fatal("Expected non-empty content") - } - - // Test empty output format (defaults to JSON) - result, err = formatResourceOutput(testData, "") - if err != nil { - t.Fatalf("formatResourceOutput with empty format failed: %v", err) - } - - if len(result.Content) == 0 { - t.Fatal("Expected non-empty content") - } +// Helper function to create a CallToolRequest with Bearer token +func requestWithBearerToken(token string, args map[string]interface{}) mcp.CallToolRequest { + req := mcp.CallToolRequest{} + req.Header = headerWithBearerToken(token) + req.Params.Arguments = args + return req } func TestHandleGetAvailableAPIResources(t *testing.T) { ctx := context.Background() t.Run("success", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - - // Mock the discovery client - clientset.Fake.PrependReactor("get", "*", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { - return true, &corev1.PodList{}, nil - }) + mock := cmd.NewMockShellExecutor() + expectedOutput := `NAME SHORTNAMES APIVERSION NAMESPACED KIND +pods po v1 true Pod +services svc v1 true Service` + mock.AddCommandString("kubectl", []string{"api-resources"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} result, err := k8sTool.handleGetAvailableAPIResources(ctx, req) @@ -110,21 +76,21 @@ func TestHandleGetAvailableAPIResources(t *testing.T) { // Check that we got some content assert.NotEmpty(t, result.Content) + assert.Contains(t, getResultText(result), "pods") }) - t.Run("error handling", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - clientset.Fake.PrependReactor("*", "*", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { - return true, nil, assert.AnError - }) + t.Run("kubectl command failure", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("kubectl", []string{"api-resources"}, "", assert.AnError) + ctx := cmd.WithShellExecutor(ctx, mock) - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} result, err := k8sTool.handleGetAvailableAPIResources(ctx, req) assert.NoError(t, err) // MCP handlers should not return Go errors assert.NotNil(t, result) - // Should handle the error gracefully + assert.True(t, result.IsError) }) } @@ -132,18 +98,12 @@ func TestHandleScaleDeployment(t *testing.T) { ctx := context.Background() t.Run("success", func(t *testing.T) { - deployment := &v1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - Spec: v1.DeploymentSpec{ - Replicas: ptr.To(int32(3)), - }, - } - clientset := fake.NewSimpleClientset(deployment) + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment scaled` + mock.AddCommandString("kubectl", []string{"scale", "deployment", "test-deployment", "--replicas", "5", "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ @@ -158,22 +118,58 @@ func TestHandleScaleDeployment(t *testing.T) { resultText := getResultText(result) assert.Contains(t, resultText, "test-deployment") + assert.Contains(t, resultText, "scaled") }) - t.Run("missing parameters", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + t.Run("missing name parameter", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ - "name": "test-deployment", - // Missing replicas parameter + // Missing name parameter (this is the required one) + "replicas": float64(3), } result, err := k8sTool.handleScaleDeployment(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "name parameter is required") + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) + }) + + t.Run("missing replicas parameter uses default", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment scaled` + mock.AddCommandString("kubectl", []string{"scale", "deployment", "test-deployment", "--replicas", "1", "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "name": "test-deployment", + } + + result, err := k8sTool.handleScaleDeployment(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "scaled") + + // Verify the command was executed with default replicas=1 + callLog := mock.GetCallLog() + assert.Len(t, callLog, 1) + assert.Equal(t, "kubectl", callLog[0].Command) + assert.Equal(t, []string{"scale", "deployment", "test-deployment", "--replicas", "1", "-n", "default"}, callLog[0].Args) }) } @@ -181,16 +177,12 @@ func TestHandleGetEvents(t *testing.T) { ctx := context.Background() t.Run("success", func(t *testing.T) { - event := &corev1.Event{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-event", - Namespace: "default", - }, - Message: "Test event message", - } - clientset := fake.NewSimpleClientset(event) + mock := cmd.NewMockShellExecutor() + expectedOutput := `{"items": [{"metadata": {"name": "test-event"}, "message": "Test event message"}]}` + mock.AddCommandString("kubectl", []string{"get", "events", "-o", "json", "--all-namespaces"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} result, err := k8sTool.handleGetEvents(ctx, req) @@ -203,8 +195,12 @@ func TestHandleGetEvents(t *testing.T) { }) t.Run("with namespace", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + mock := cmd.NewMockShellExecutor() + expectedOutput := `{"items": []}` + mock.AddCommandString("kubectl", []string{"get", "events", "-o", "json", "-n", "custom-namespace"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ @@ -214,7 +210,7 @@ func TestHandleGetEvents(t *testing.T) { result, err := k8sTool.handleGetEvents(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - // Should not error even if no events found + assert.False(t, result.IsError) }) } @@ -222,8 +218,10 @@ func TestHandlePatchResource(t *testing.T) { ctx := context.Background() t.Run("missing parameters", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ @@ -235,17 +233,19 @@ func TestHandlePatchResource(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) t.Run("valid parameters", func(t *testing.T) { - deployment := &v1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-deployment", - Namespace: "default", - }, - } - clientset := fake.NewSimpleClientset(deployment) - k8sTool := newTestK8sTool(clientset) + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment patched` + mock.AddCommandString("kubectl", []string{"patch", "deployment", "test-deployment", "-p", `{"spec":{"replicas":5}}`, "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ @@ -257,310 +257,286 @@ func TestHandlePatchResource(t *testing.T) { result, err := k8sTool.handlePatchResource(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - // Should attempt to patch (may fail in test env but validates parameters) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "patched") }) } -func TestHandleDeleteResource(t *testing.T) { +func TestHandlePatchStatus(t *testing.T) { ctx := context.Background() t.Run("missing parameters", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ - "resource_type": "pod", - // Missing resource_name + "resource_type": "customresource", + // Missing resource_name and patch } - result, err := k8sTool.handleDeleteResource(ctx, req) + result, err := k8sTool.handlePatchStatus(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) - }) - - t.Run("valid parameters", func(t *testing.T) { - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "default", - }, - } - clientset := fake.NewSimpleClientset(pod) - k8sTool := newTestK8sTool(clientset) - - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "pod", - "resource_name": "test-pod", - } - result, err := k8sTool.handleDeleteResource(ctx, req) - assert.NoError(t, err) - assert.NotNil(t, result) - // Should attempt to delete (may succeed or fail depending on implementation) + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) -} - -func TestHandleCheckServiceConnectivity(t *testing.T) { - ctx := context.Background() - t.Run("missing service_name", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) - - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{} - - result, err := k8sTool.handleCheckServiceConnectivity(ctx, req) - assert.NoError(t, err) - assert.NotNil(t, result) - assert.True(t, result.IsError) - }) + t.Run("valid parameters", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `customresource.kagent.dev/test-resource patched` + mock.AddCommandString("kubectl", []string{"patch", "customresource", "test-resource", "--subresource=status", "--type=merge", "-p", `{"status":{"phase":"Ready"}}`, "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) - t.Run("valid service_name", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ - "service_name": "test-service.default.svc.cluster.local:80", + "resource_type": "customresource", + "resource_name": "test-resource", + "patch": `{"status":{"phase":"Ready"}}`, } - result, err := k8sTool.handleCheckServiceConnectivity(ctx, req) + result, err := k8sTool.handlePatchStatus(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - // Should attempt connectivity check (will likely fail in test env but validates params) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "patched") }) } -func TestHandleKubectlDescribeTool(t *testing.T) { +func TestHandleDeleteResource(t *testing.T) { ctx := context.Background() t.Run("missing parameters", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ - "resource_type": "deployment", + "resource_type": "pod", // Missing resource_name } - result, err := k8sTool.handleKubectlDescribeTool(ctx, req) + result, err := k8sTool.handleDeleteResource(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) t.Run("valid parameters", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment deleted` + mock.AddCommandString("kubectl", []string{"delete", "deployment", "test-deployment", "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ "resource_type": "deployment", "resource_name": "test-deployment", - "namespace": "default", } - result, err := k8sTool.handleKubectlDescribeTool(ctx, req) + result, err := k8sTool.handleDeleteResource(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - // Should attempt to describe (may fail in test env but validates parameters) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "deleted") }) } -func TestHandleGenerateResource(t *testing.T) { +func TestHandleCheckServiceConnectivity(t *testing.T) { ctx := context.Background() - t.Run("missing parameters", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - mockLLM := newMockLLM(&llms.ContentResponse{}, nil) - k8sTool := newTestK8sToolWithLLM(clientset, mockLLM) + t.Run("missing service_name", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "istio_auth_policy", - // Missing resource_description - } + req.Params.Arguments = map[string]interface{}{} - result, err := k8sTool.handleGenerateResource(ctx, req) + result, err := k8sTool.handleCheckServiceConnectivity(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "resource_type and resource_description parameters are required") + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) - t.Run("valid istio auth policy generation", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - expectedResponse := `apiVersion: security.istio.io/v1beta1 -kind: PeerAuthentication -metadata: - name: test-auth-policy - namespace: default -spec: - selector: - matchLabels: - app: test-app - mtls: - mode: STRICT` + t.Run("valid service_name", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() - mockLLM := newMockLLM(&llms.ContentResponse{ - Choices: []*llms.ContentChoice{ - {Content: expectedResponse}, - }, - }, nil) - k8sTool := newTestK8sToolWithLLM(clientset, mockLLM) + // Mock the pod creation, wait, and exec commands using partial matchers + mock.AddPartialMatcherString("kubectl", []string{"run", "*", "--image=curlimages/curl", "-n", "default", "--restart=Never", "--", "sleep", "3600"}, "pod/curl-test-123 created", nil) + mock.AddPartialMatcherString("kubectl", []string{"wait", "--for=condition=ready", "*", "-n", "default", "--timeout=60s"}, "pod/curl-test-123 condition met", nil) + mock.AddPartialMatcherString("kubectl", []string{"exec", "*", "-n", "default", "--", "curl", "-s", "test-service.default.svc.cluster.local:80"}, "Connection successful", nil) + mock.AddPartialMatcherString("kubectl", []string{"delete", "pod", "*", "-n", "default", "--ignore-not-found"}, "pod deleted", nil) + + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ - "resource_type": "istio_auth_policy", - "resource_description": "A peer authentication policy for strict mTLS", + "service_name": "test-service.default.svc.cluster.local:80", } - result, err := k8sTool.handleGenerateResource(ctx, req) + result, err := k8sTool.handleCheckServiceConnectivity(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - assert.False(t, result.IsError) - - resultText := getResultText(result) - assert.Equal(t, expectedResponse, resultText) - - // Verify the mock was called - assert.Equal(t, 1, mockLLM.called) + // Should attempt connectivity check (may succeed or fail but validates params) }) +} - t.Run("valid gateway api gateway generation", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - expectedResponse := `apiVersion: gateway.networking.k8s.io/v1beta1 -kind: Gateway -metadata: - name: test-gateway - namespace: default -spec: - gatewayClassName: istio - listeners: - - name: http - port: 80` +func TestHandleKubectlDescribeTool(t *testing.T) { + ctx := context.Background() - mockLLM := newMockLLM(&llms.ContentResponse{ - Choices: []*llms.ContentChoice{ - {Content: expectedResponse}, - }, - }, nil) - k8sTool := newTestK8sToolWithLLM(clientset, mockLLM) + t.Run("missing parameters", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ - "resource_type": "gateway_api_gateway", - "resource_description": "A gateway for HTTP traffic", + "resource_type": "deployment", + // Missing resource_name } - result, err := k8sTool.handleGenerateResource(ctx, req) + result, err := k8sTool.handleKubectlDescribeTool(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - assert.False(t, result.IsError) - - resultText := getResultText(result) - assert.Equal(t, expectedResponse, resultText) + assert.True(t, result.IsError) - // Verify the mock was called - assert.Equal(t, 1, mockLLM.called) + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) - t.Run("unsupported resource type", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - mockLLM := newMockLLM(&llms.ContentResponse{}, nil) - k8sTool := newTestK8sToolWithLLM(clientset, mockLLM) + t.Run("valid parameters", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `Name: test-deployment +Namespace: default +Labels: app=test` + mock.AddCommandString("kubectl", []string{"describe", "deployment", "test-deployment", "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ - "resource_type": "unsupported_resource_type", - "resource_description": "Some description", + "resource_type": "deployment", + "resource_name": "test-deployment", + "namespace": "default", } - result, err := k8sTool.handleGenerateResource(ctx, req) + result, err := k8sTool.handleKubectlDescribeTool(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "resource type unsupported_resource_type not found") + assert.False(t, result.IsError) - // Verify the mock was never called since validation failed - assert.Equal(t, 0, mockLLM.called) + resultText := getResultText(result) + assert.Contains(t, resultText, "test-deployment") }) +} - t.Run("LLM generation error", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - mockLLM := newMockLLM(nil, assert.AnError) - k8sTool := newTestK8sToolWithLLM(clientset, mockLLM) +func TestHandleKubectlGetEnhanced(t *testing.T) { + ctx := context.Background() - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "istio_auth_policy", - "resource_description": "A peer authentication policy for strict mTLS", - } + t.Run("missing resource_type", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) - result, err := k8sTool.handleGenerateResource(ctx, req) + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} + result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "failed to generate content") - // Verify the mock was called - assert.Equal(t, 1, mockLLM.called) + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) - t.Run("LLM empty response", func(t *testing.T) { - clientset := fake.NewSimpleClientset() - mockLLM := newMockLLM(&llms.ContentResponse{ - Choices: []*llms.ContentChoice{}, // Empty choices - }, nil) - k8sTool := newTestK8sToolWithLLM(clientset, mockLLM) + t.Run("valid resource_type", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `NAME READY STATUS RESTARTS AGE` + mock.AddCommandString("kubectl", []string{"get", "pods", "-o", "wide"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "istio_auth_policy", - "resource_description": "A peer authentication policy for strict mTLS", - } - - result, err := k8sTool.handleGenerateResource(ctx, req) + req.Params.Arguments = map[string]interface{}{"resource_type": "pods"} + result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "empty response from model") - - // Verify the mock was called - assert.Equal(t, 1, mockLLM.called) + assert.False(t, result.IsError) }) } func TestHandleKubectlLogsEnhanced(t *testing.T) { ctx := context.Background() - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) t.Run("missing pod_name", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} result, err := k8sTool.handleKubectlLogsEnhanced(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) t.Run("valid pod_name", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `log line 1 +log line 2` + mock.AddCommandString("kubectl", []string{"logs", "test-pod", "-n", "default", "--tail", "50"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{"pod_name": "test-pod"} result, err := k8sTool.handleKubectlLogsEnhanced(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) + assert.False(t, result.IsError) }) } func TestHandleApplyManifest(t *testing.T) { + ctx := context.Background() t.Run("apply manifest from string", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() manifest := `apiVersion: v1 kind: Pod metadata: @@ -572,11 +548,10 @@ spec: expectedOutput := `pod/test-pod created` // Use partial matcher to handle dynamic temp file names - mock.AddPartialMatcherString("kubectl", []string{"apply", "-f", "*"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ @@ -604,11 +579,10 @@ spec: }) t.Run("missing manifest parameter", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(ctx, mock) - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ @@ -628,18 +602,18 @@ spec: } func TestHandleExecCommand(t *testing.T) { + ctx := context.Background() t.Run("exec command in pod", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `total 8 drwxr-xr-x 1 root root 4096 Jan 1 12:00 . drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` // The implementation passes the command as a single string after -- mock.AddCommandString("kubectl", []string{"exec", "mypod", "-n", "default", "--", "ls -la"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(ctx, mock) - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ @@ -665,11 +639,10 @@ drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` }) t.Run("missing required parameters", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ @@ -683,22 +656,22 @@ drwxr-xr-x 1 root root 4096 Jan 1 12:00 ..` assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "pod_name and command parameters are required") - // Verify no commands were executed + // Verify no commands were executed since parameters are missing callLog := mock.GetCallLog() assert.Len(t, callLog, 0) }) } func TestHandleRollout(t *testing.T) { + ctx := context.Background() t.Run("rollout restart deployment", func(t *testing.T) { - mock := utils.NewMockShellExecutor() + mock := cmd.NewMockShellExecutor() expectedOutput := `deployment.apps/myapp restarted` mock.AddCommandString("kubectl", []string{"rollout", "restart", "deployment/myapp", "-n", "default"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + ctx := cmd.WithShellExecutor(ctx, mock) - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ @@ -724,46 +697,11 @@ func TestHandleRollout(t *testing.T) { assert.Equal(t, []string{"rollout", "restart", "deployment/myapp", "-n", "default"}, callLog[0].Args) }) - t.Run("rollout status check", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `deployment "myapp" successfully rolled out` - - mock.AddCommandString("kubectl", []string{"rollout", "status", "deployment/myapp", "-n", "default"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) - - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) - - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "action": "status", - "resource_type": "deployment", - "resource_name": "myapp", - "namespace": "default", - } - - result, err := k8sTool.handleRollout(ctx, req) - assert.NoError(t, err) - assert.NotNil(t, result) - assert.False(t, result.IsError) - - // Verify the expected output - content := getResultText(result) - assert.Contains(t, content, "successfully rolled out") - - // Verify the correct kubectl command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "kubectl", callLog[0].Command) - assert.Equal(t, []string{"rollout", "status", "deployment/myapp", "-n", "default"}, callLog[0].Args) - }) - t.Run("missing required parameters", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - ctx := utils.WithShellExecutor(context.Background(), mock) + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sTool() req := mcp.CallToolRequest{} req.Params.Arguments = map[string]interface{}{ @@ -777,239 +715,941 @@ func TestHandleRollout(t *testing.T) { assert.True(t, result.IsError) assert.Contains(t, getResultText(result), "required") - // Verify no commands were executed + // Verify no commands were executed since parameters are missing callLog := mock.GetCallLog() assert.Len(t, callLog, 0) }) } -func TestHandleLabelResource(t *testing.T) { +// Mock LLM for testing +type mockLLM struct { + called int + response *llms.ContentResponse + error error +} + +func newMockLLM(response *llms.ContentResponse, err error) *mockLLM { + return &mockLLM{ + response: response, + error: err, + } +} + +func (m *mockLLM) Call(ctx context.Context, prompt string, options ...llms.CallOption) (string, error) { + return "", nil +} + +func (m *mockLLM) GenerateContent(ctx context.Context, _ []llms.MessageContent, options ...llms.CallOption) (*llms.ContentResponse, error) { + m.called++ + return m.response, m.error +} + +func TestHandleGenerateResource(t *testing.T) { ctx := context.Background() - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + + t.Run("success", func(t *testing.T) { + expectedYAML := `apiVersion: security.istio.io/v1beta1 +kind: PeerAuthentication +metadata: + name: default + namespace: foo +spec: + mtls: + mode: STRICT` + + mockLLM := newMockLLM(&llms.ContentResponse{ + Choices: []*llms.ContentChoice{ + {Content: expectedYAML}, + }, + }, nil) + + k8sTool := newTestK8sToolWithLLM(mockLLM) + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "istio_auth_policy", + "resource_description": "A peer authentication policy for strict mTLS", + } + + result, err := k8sTool.handleGenerateResource(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "PeerAuthentication") + assert.Contains(t, resultText, "STRICT") + + // Verify the mock was called + assert.Equal(t, 1, mockLLM.called) + }) t.Run("missing parameters", func(t *testing.T) { + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} - result, err := k8sTool.handleLabelResource(ctx, req) + req.Params.Arguments = map[string]interface{}{ + "resource_type": "istio_auth_policy", + // Missing resource_description + } + + result, err := k8sTool.handleGenerateResource(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "required") }) - t.Run("valid parameters", func(t *testing.T) { + t.Run("no LLM model", func(t *testing.T) { + k8sTool := newTestK8sTool() // No LLM model + req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"resource_type": "pod", "resource_name": "test-pod", "labels": "app=test"} - result, err := k8sTool.handleLabelResource(ctx, req) + req.Params.Arguments = map[string]interface{}{ + "resource_type": "istio_auth_policy", + "resource_description": "A peer authentication policy for strict mTLS", + } + + result, err := k8sTool.handleGenerateResource(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "No LLM client present") + }) + + t.Run("invalid resource type", func(t *testing.T) { + mockLLM := newMockLLM(&llms.ContentResponse{ + Choices: []*llms.ContentChoice{ + {Content: "test"}, + }, + }, nil) + + k8sTool := newTestK8sToolWithLLM(mockLLM) + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "invalid_resource_type", + "resource_description": "A test resource", + } + + result, err := k8sTool.handleGenerateResource(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "resource type invalid_resource_type not found") + + // Verify the mock was not called + assert.Equal(t, 0, mockLLM.called) }) } +// Test additional handlers that were missing tests func TestHandleAnnotateResource(t *testing.T) { ctx := context.Background() - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + + t.Run("success", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment annotated` + mock.AddCommandString("kubectl", []string{"annotate", "deployment", "test-deployment", "key1=value1", "key2=value2", "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", + "annotations": "key1=value1 key2=value2", + "namespace": "default", + } + + result, err := k8sTool.handleAnnotateResource(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "annotated") + }) t.Run("missing parameters", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "deployment", + // Missing resource_name and annotations + } + result, err := k8sTool.handleAnnotateResource(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "required") + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) +} + +func TestHandleLabelResource(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment labeled` + mock.AddCommandString("kubectl", []string{"label", "deployment", "test-deployment", "env=prod", "version=1.0", "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() - t.Run("valid parameters", func(t *testing.T) { req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"resource_type": "pod", "resource_name": "test-pod", "annotations": "foo=bar"} - result, err := k8sTool.handleAnnotateResource(ctx, req) + req.Params.Arguments = map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", + "labels": "env=prod version=1.0", + "namespace": "default", + } + + result, err := k8sTool.handleLabelResource(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "labeled") + }) + + t.Run("missing parameters", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "deployment", + // Missing resource_name and labels + } + + result, err := k8sTool.handleLabelResource(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "required") + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) } func TestHandleRemoveAnnotation(t *testing.T) { ctx := context.Background() - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) - t.Run("missing parameters", func(t *testing.T) { + t.Run("success", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment annotated` + mock.AddCommandString("kubectl", []string{"annotate", "deployment", "test-deployment", "key1-", "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", + "annotation_key": "key1", + "namespace": "default", + } + result, err := k8sTool.handleRemoveAnnotation(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - assert.True(t, result.IsError) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "annotated") }) - t.Run("valid parameters", func(t *testing.T) { + t.Run("missing parameters", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"resource_type": "pod", "resource_name": "test-pod", "annotation_key": "foo"} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "deployment", + // Missing resource_name and annotation_key + } + result, err := k8sTool.handleRemoveAnnotation(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "required") + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) } func TestHandleRemoveLabel(t *testing.T) { ctx := context.Background() - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) - t.Run("missing parameters", func(t *testing.T) { + t.Run("success", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment labeled` + mock.AddCommandString("kubectl", []string{"label", "deployment", "test-deployment", "env-", "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", + "label_key": "env", + "namespace": "default", + } + result, err := k8sTool.handleRemoveLabel(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - assert.True(t, result.IsError) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "labeled") }) - t.Run("valid parameters", func(t *testing.T) { + t.Run("missing parameters", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"resource_type": "pod", "resource_name": "test-pod", "label_key": "foo"} + req.Params.Arguments = map[string]interface{}{ + "resource_type": "deployment", + // Missing resource_name and label_key + } + result, err := k8sTool.handleRemoveLabel(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "required") + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) } func TestHandleCreateResourceFromURL(t *testing.T) { ctx := context.Background() - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) - t.Run("missing url", func(t *testing.T) { + t.Run("success", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment created` + mock.AddCommandString("kubectl", []string{"create", "-f", "https://example.com/manifest.yaml", "-n", "default"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{ + "url": "https://example.com/manifest.yaml", + "namespace": "default", + } + result, err := k8sTool.handleCreateResourceFromURL(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) - assert.True(t, result.IsError) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "created") }) - t.Run("valid url", func(t *testing.T) { + t.Run("missing url parameter", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + ctx := cmd.WithShellExecutor(context.Background(), mock) + + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{"url": "http://example.com/manifest.yaml"} + req.Params.Arguments = map[string]interface{}{ + // Missing url parameter + } + result, err := k8sTool.handleCreateResourceFromURL(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "url parameter is required") + + // Verify no commands were executed since parameters are missing + callLog := mock.GetCallLog() + assert.Len(t, callLog, 0) }) } func TestHandleGetClusterConfiguration(t *testing.T) { ctx := context.Background() - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) - req := mcp.CallToolRequest{} - result, err := k8sTool.handleGetClusterConfiguration(ctx, req) - assert.NoError(t, err) - assert.NotNil(t, result) + t.Run("success", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `apiVersion: v1 +clusters: +- cluster: + server: https://kubernetes.default.svc + name: default +contexts: +- context: + cluster: default + user: default + name: default +current-context: default +kind: Config +preferences: {} +users: +- name: default` + mock.AddCommandString("kubectl", []string{"config", "view", "-o", "json"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() + + req := mcp.CallToolRequest{} + result, err := k8sTool.handleGetClusterConfiguration(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + resultText := getResultText(result) + assert.Contains(t, resultText, "current-context") + assert.Contains(t, resultText, "clusters") + }) } -func TestHandleGetResourceYAML(t *testing.T) { +// Tests for Bearer token passing to kubectl commands +func TestBearerTokenPassthrough(t *testing.T) { ctx := context.Background() - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) - // This handler is registered as an anonymous func, so we test the logic directly - // Simulate the parameters - resourceType := "pod" - resourceName := "test-pod" - namespace := "default" - args := []string{"get", resourceType, resourceName, "-o", "yaml", "-n", namespace} - result, err := k8sTool.runKubectlCommand(ctx, args) - assert.NoError(t, err) - assert.NotNil(t, result) -} + t.Run("get resources with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `NAME READY STATUS RESTARTS AGE` + mock.AddCommandString("kubectl", []string{"get", "pods", "-o", "wide", "--token", "test-token-123"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) -func TestHandleKubectlGetTool(t *testing.T) { - t.Run("success with mocked kubectl", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - expectedOutput := `NAME READY STATUS RESTARTS AGE -pod1 1/1 Running 0 1d -pod2 1/1 Running 0 2d` + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("test-token-123", map[string]interface{}{"resource_type": "pods"}) + result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) - mock.AddCommandString("kubectl", []string{"get", "pods", "-n", "default", "-o", "json"}, expectedOutput, nil) - ctx := utils.WithShellExecutor(context.Background(), mock) + // Verify the command was executed with the token + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Equal(t, "kubectl", callLog[0].Command) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "test-token-123") + }) - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + t.Run("scale deployment with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment scaled` + mock.AddCommandString("kubectl", []string{"scale", "deployment", "test-deployment", "--replicas", "5", "-n", "default", "--token", "my-auth-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "pods", + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("my-auth-token", map[string]interface{}{ + "name": "test-deployment", + "replicas": float64(5), + }) + + result, err := k8sTool.handleScaleDeployment(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + // Verify the command was executed with the token + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "my-auth-token") + }) + + t.Run("get pod logs with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `log line 1 +log line 2` + mock.AddCommandString("kubectl", []string{"logs", "test-pod", "-n", "default", "--tail", "50", "--token", "logs-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("logs-token", map[string]interface{}{"pod_name": "test-pod"}) + result, err := k8sTool.handleKubectlLogsEnhanced(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "logs-token") + }) + + t.Run("delete resource with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment deleted` + mock.AddCommandString("kubectl", []string{"delete", "deployment", "test-deployment", "-n", "default", "--token", "delete-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("delete-token", map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", + }) + + result, err := k8sTool.handleDeleteResource(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "delete-token") + }) + + t.Run("patch resource with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment patched` + mock.AddCommandString("kubectl", []string{"patch", "deployment", "test-deployment", "-p", `{"spec":{"replicas":5}}`, "-n", "default", "--token", "patch-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("patch-token", map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", + "patch": `{"spec":{"replicas":5}}`, + }) + + result, err := k8sTool.handlePatchResource(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "patch-token") + }) + + t.Run("describe resource with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `Name: test-deployment` + mock.AddCommandString("kubectl", []string{"describe", "deployment", "test-deployment", "-n", "default", "--token", "describe-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("describe-token", map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", "namespace": "default", - "output": "json", - } + }) - result, err := k8sTool.handleKubectlGetTool(ctx, req) + result, err := k8sTool.handleKubectlDescribeTool(ctx, req) assert.NoError(t, err) assert.NotNil(t, result) assert.False(t, result.IsError) - // Verify the expected output - content := getResultText(result) - assert.Contains(t, content, "pod1") - assert.Contains(t, content, "pod2") + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "describe-token") + }) + + t.Run("rollout with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/myapp restarted` + mock.AddCommandString("kubectl", []string{"rollout", "restart", "deployment/myapp", "-n", "default", "--token", "rollout-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("rollout-token", map[string]interface{}{ + "action": "restart", + "resource_type": "deployment", + "resource_name": "myapp", + "namespace": "default", + }) + + result, err := k8sTool.handleRollout(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) - // Verify the correct kubectl command was called callLog := mock.GetCallLog() require.Len(t, callLog, 1) - assert.Equal(t, "kubectl", callLog[0].Command) - assert.Equal(t, []string{"get", "pods", "-n", "default", "-o", "json"}, callLog[0].Args) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "rollout-token") }) - t.Run("kubectl command failure", func(t *testing.T) { - mock := utils.NewMockShellExecutor() - mock.AddCommandString("kubectl", []string{"get", "invalidresource", "-o", "json"}, "", assert.AnError) - ctx := utils.WithShellExecutor(context.Background(), mock) + t.Run("get events with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `{"items": []}` + mock.AddCommandString("kubectl", []string{"get", "events", "-o", "json", "--all-namespaces", "--token", "events-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) - clientset := fake.NewSimpleClientset() - k8sTool := newTestK8sTool(clientset) + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("events-token", nil) + result, err := k8sTool.handleGetEvents(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) - req := mcp.CallToolRequest{} - req.Params.Arguments = map[string]interface{}{ - "resource_type": "invalidresource", - } + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "events-token") + }) - result, err := k8sTool.handleKubectlGetTool(ctx, req) - assert.NoError(t, err) // MCP handlers should not return Go errors + t.Run("exec command with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `total 8` + mock.AddCommandString("kubectl", []string{"exec", "mypod", "-n", "default", "--", "ls -la", "--token", "exec-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("exec-token", map[string]interface{}{ + "pod_name": "mypod", + "namespace": "default", + "command": "ls -la", + }) + + result, err := k8sTool.handleExecCommand(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "exec-token") + }) + + t.Run("annotate resource with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment annotated` + mock.AddCommandString("kubectl", []string{"annotate", "deployment", "test-deployment", "key1=value1", "--token", "annotate-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("annotate-token", map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", + "annotations": "key1=value1", + }) + + result, err := k8sTool.handleAnnotateResource(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "annotate-token") + }) + + t.Run("label resource with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment labeled` + mock.AddCommandString("kubectl", []string{"label", "deployment", "test-deployment", "env=prod", "--token", "label-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("label-token", map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", + "labels": "env=prod", + }) + + result, err := k8sTool.handleLabelResource(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "label-token") + }) + + t.Run("api resources with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `NAME SHORTNAMES APIVERSION NAMESPACED KIND` + mock.AddCommandString("kubectl", []string{"api-resources", "--token", "api-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("api-token", nil) + result, err := k8sTool.handleGetAvailableAPIResources(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "api-token") + }) + + t.Run("cluster configuration with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `{"current-context": "default"}` + mock.AddCommandString("kubectl", []string{"config", "view", "-o", "json", "--token", "config-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("config-token", nil) + result, err := k8sTool.handleGetClusterConfiguration(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "config-token") + }) + + t.Run("remove annotation with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment annotated` + mock.AddCommandString("kubectl", []string{"annotate", "deployment", "test-deployment", "key1-", "--token", "remove-anno-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("remove-anno-token", map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", + "annotation_key": "key1", + }) + + result, err := k8sTool.handleRemoveAnnotation(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "remove-anno-token") + }) + + t.Run("remove label with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment labeled` + mock.AddCommandString("kubectl", []string{"label", "deployment", "test-deployment", "env-", "--token", "remove-label-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("remove-label-token", map[string]interface{}{ + "resource_type": "deployment", + "resource_name": "test-deployment", + "label_key": "env", + }) + + result, err := k8sTool.handleRemoveLabel(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "remove-label-token") + }) + + t.Run("create resource from URL with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `deployment.apps/test-deployment created` + mock.AddCommandString("kubectl", []string{"create", "-f", "https://example.com/manifest.yaml", "-n", "default", "--token", "url-token"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("url-token", map[string]interface{}{ + "url": "https://example.com/manifest.yaml", + "namespace": "default", + }) + + result, err := k8sTool.handleCreateResourceFromURL(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "url-token") + }) + + t.Run("apply manifest with bearer token", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + manifest := `apiVersion: v1 +kind: Pod +metadata: + name: test-pod` + expectedOutput := `pod/test-pod created` + // Use partial matcher since temp file name is dynamic + mock.AddPartialMatcherString("kubectl", []string{"apply", "-f"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(true) + req := requestWithBearerToken("apply-token", map[string]interface{}{ + "manifest": manifest, + }) + + result, err := k8sTool.handleApplyManifest(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--token") + assert.Contains(t, callLog[0].Args, "apply-token") + }) + + t.Run("returns error when passthrough true and authorization header missing", func(t *testing.T) { + k8sTool := newTestK8sToolWithPassthrough(true) + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"resource_type": "pods"} + result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + assert.NoError(t, err) assert.NotNil(t, result) assert.True(t, result.IsError) - assert.Contains(t, getResultText(result), "command kubectl failed") + assert.Contains(t, getResultText(result), "Bearer token required") }) -} -func newMockLLM(response *llms.ContentResponse, err error) *mockLLM { - return &mockLLM{ - called: 0, - response: response, - error: err, - } -} + t.Run("no token when passthrough false and authorization header missing", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `NAME READY STATUS RESTARTS AGE` + // No --token in expected args when passthrough is false + mock.AddCommandString("kubectl", []string{"get", "pods", "-o", "wide"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) -// not synchronized, don't use concurrently! -type mockLLM struct { - called int - response *llms.ContentResponse - error error -} + k8sTool := newTestK8sToolWithPassthrough(false) + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"resource_type": "pods"} + // No Header set on request + result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) -func (m *mockLLM) Call(ctx context.Context, prompt string, options ...llms.CallOption) (string, error) { - return llms.GenerateFromSinglePrompt(ctx, m, prompt, options...) + // Verify no --token was added + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.NotContains(t, callLog[0].Args, "--token") + }) + + t.Run("no token when passthrough false and authorization header is not bearer", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + expectedOutput := `NAME READY STATUS RESTARTS AGE` + // No --token when passthrough is false + mock.AddCommandString("kubectl", []string{"get", "pods", "-o", "wide"}, expectedOutput, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sToolWithPassthrough(false) + req := mcp.CallToolRequest{} + req.Header = http.Header{} + req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + req.Params.Arguments = map[string]interface{}{"resource_type": "pods"} + result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + // Verify no --token was added + callLog := mock.GetCallLog() + require.Len(t, callLog, 1) + assert.NotContains(t, callLog[0].Args, "--token") + }) } -func (m *mockLLM) GenerateContent(ctx context.Context, _ []llms.MessageContent, options ...llms.CallOption) (*llms.ContentResponse, error) { - var opts llms.CallOptions - for _, opt := range options { - opt(&opts) +func TestHandleWaitForCondition(t *testing.T) { + k8sTool := newTestK8sTool() + + tests := []struct { + name string + args map[string]interface{} + mock []string + mockErr error + wantErr bool + }{ + { + name: "success with defaults", + args: map[string]interface{}{"resource_type": "deployment", "resource_name": "myapp", "condition": "Available"}, + mock: []string{"wait", "deployment/myapp", "--for=condition=Available", "-n", "default", "--timeout=60s"}, + }, + { + name: "success with explicit namespace and timeout", + args: map[string]interface{}{"resource_type": "pod", "resource_name": "mypod", "condition": "Ready", "namespace": "kube-system", "timeout_seconds": float64(120)}, + mock: []string{"wait", "pod/mypod", "--for=condition=Ready", "-n", "kube-system", "--timeout=120s"}, + }, + { + name: "missing resource_type", + args: map[string]interface{}{"resource_name": "myapp", "condition": "Available"}, + wantErr: true, + }, + { + name: "missing resource_name", + args: map[string]interface{}{"resource_type": "deployment", "condition": "Available"}, + wantErr: true, + }, + { + name: "missing condition", + args: map[string]interface{}{"resource_type": "deployment", "resource_name": "myapp"}, + wantErr: true, + }, + { + name: "zero timeout", + args: map[string]interface{}{"resource_type": "deployment", "resource_name": "myapp", "condition": "Available", "timeout_seconds": float64(0)}, + wantErr: true, + }, + { + name: "kubectl error propagated", + args: map[string]interface{}{"resource_type": "deployment", "resource_name": "slow-app", "condition": "Available", "timeout_seconds": float64(5)}, + mock: []string{"wait", "deployment/slow-app", "--for=condition=Available", "-n", "default", "--timeout=5s"}, + mockErr: assert.AnError, + wantErr: true, + }, } - if opts.StreamingFunc != nil && len(m.response.Choices) > 0 { - if err := opts.StreamingFunc(ctx, []byte(m.response.Choices[0].Content)); err != nil { - return nil, err - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + if tt.mock != nil { + mock.AddCommandString("kubectl", tt.mock, "", tt.mockErr) + } + ctx := cmd.WithShellExecutor(context.Background(), mock) + + req := mcp.CallToolRequest{} + req.Params.Arguments = tt.args + + result, err := k8sTool.handleWaitForCondition(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, tt.wantErr, result.IsError) + }) } - - m.called++ - - return m.response, m.error } diff --git a/pkg/kubescape/kubescape.go b/pkg/kubescape/kubescape.go new file mode 100644 index 00000000..e2227fa4 --- /dev/null +++ b/pkg/kubescape/kubescape.go @@ -0,0 +1,1206 @@ +package kubescape + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/kagent-dev/tools/internal/errors" + "github.com/kagent-dev/tools/internal/telemetry" + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + corev1 "k8s.io/api/core/v1" + apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + defaultKubescapeNamespace = "kubescape" + + // CRD names + vulnerabilityManifestsCRD = "vulnerabilitymanifests.spdx.softwarecomposition.kubescape.io" + workloadConfigurationScansCRD = "workloadconfigurationscans.spdx.softwarecomposition.kubescape.io" + applicationProfilesCRD = "applicationprofiles.spdx.softwarecomposition.kubescape.io" + networkNeighborhoodsCRD = "networkneighborhoods.spdx.softwarecomposition.kubescape.io" + sbomSyftsCRD = "sbomsyfts.spdx.softwarecomposition.kubescape.io" + + // Pod labels + operatorPodLabel = "app.kubernetes.io/name=kubescape-operator" + storagePodLabel = "app.kubernetes.io/name=storage" +) + +// KubescapeTool holds the clients for Kubescape and Kubernetes APIs +type KubescapeTool struct { + spdxClient spdxv1beta1.SpdxV1beta1Interface + k8sClient kubernetes.Interface + apiExtClient apiextensionsclientset.Interface + initError error +} + +// NewKubescapeTool creates a new KubescapeTool with Kubernetes clients +func NewKubescapeTool(kubeconfig string) *KubescapeTool { + tool := &KubescapeTool{} + + config, err := getKubeConfig(kubeconfig) + if err != nil { + tool.initError = fmt.Errorf("failed to create kubernetes config: %w", err) + return tool + } + + // Create standard Kubernetes client + k8sClient, err := kubernetes.NewForConfig(config) + if err != nil { + tool.initError = fmt.Errorf("failed to create kubernetes client: %w", err) + return tool + } + tool.k8sClient = k8sClient + + // Create API extensions client for CRD checks + apiExtClient, err := apiextensionsclientset.NewForConfig(config) + if err != nil { + tool.initError = fmt.Errorf("failed to create apiextensions client: %w", err) + return tool + } + tool.apiExtClient = apiExtClient + + // Create Kubescape storage client + spdxClient, err := spdxv1beta1.NewForConfig(config) + if err != nil { + tool.initError = fmt.Errorf("failed to create kubescape client: %w", err) + return tool + } + tool.spdxClient = spdxClient + + return tool +} + +func getKubeConfig(kubeconfig string) (*rest.Config, error) { + if kubeconfig != "" { + return clientcmd.BuildConfigFromFlags("", kubeconfig) + } + // Try in-cluster config first, then fall back to default kubeconfig location + config, err := rest.InClusterConfig() + if err != nil { + // Fall back to default kubeconfig + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + return kubeConfig.ClientConfig() + } + return config, nil +} + +// HealthCheckResult represents the result of a health check +type HealthCheckResult struct { + Healthy bool `json:"healthy"` + Checks map[string]CheckStatus `json:"checks"` + Summary string `json:"summary"` + Recommendations []string `json:"recommendations,omitempty"` +} + +// CheckStatus represents the status of a single check +type CheckStatus struct { + Status string `json:"status"` + Message string `json:"message"` + Details interface{} `json:"details,omitempty"` +} + +// handleCheckHealth verifies Kubescape operator installation and readiness +func (k *KubescapeTool) handleCheckHealth(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if k.initError != nil { + toolErr := errors.NewKubescapeError("check_health", k.initError) + return toolErr.ToMCPResult(), nil + } + + namespace := mcp.ParseString(request, "namespace", defaultKubescapeNamespace) + + result := HealthCheckResult{ + Healthy: true, + Checks: make(map[string]CheckStatus), + } + var recommendations []string + + // Check 1: Namespace exists + _, err := k.k8sClient.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + result.Checks["namespace"] = CheckStatus{ + Status: "error", + Message: fmt.Sprintf("Namespace '%s' not found", namespace), + } + result.Healthy = false + recommendations = append(recommendations, fmt.Sprintf("Create the namespace: kubectl create namespace %s", namespace)) + } else { + result.Checks["namespace"] = CheckStatus{ + Status: "error", + Message: fmt.Sprintf("Failed to check namespace: %v", err), + } + result.Healthy = false + } + } else { + result.Checks["namespace"] = CheckStatus{ + Status: "ok", + Message: fmt.Sprintf("Namespace '%s' exists", namespace), + } + } + + // Check 2: Operator pods running + operatorPods, err := k.k8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: operatorPodLabel, + }) + if err != nil { + result.Checks["operator_pods"] = CheckStatus{ + Status: "error", + Message: fmt.Sprintf("Failed to list operator pods: %v", err), + } + result.Healthy = false + } else if len(operatorPods.Items) == 0 { + result.Checks["operator_pods"] = CheckStatus{ + Status: "error", + Message: "No operator pods found", + } + result.Healthy = false + recommendations = append(recommendations, "Install Kubescape operator: helm upgrade --install kubescape kubescape/kubescape-operator -n kubescape --create-namespace") + } else { + runningCount := 0 + podDetails := []map[string]string{} + for _, pod := range operatorPods.Items { + status := string(pod.Status.Phase) + if pod.Status.Phase == corev1.PodRunning { + runningCount++ + } + podDetails = append(podDetails, map[string]string{ + "name": pod.Name, + "status": status, + }) + } + if runningCount == len(operatorPods.Items) { + result.Checks["operator_pods"] = CheckStatus{ + Status: "ok", + Message: fmt.Sprintf("%d/%d pods running", runningCount, len(operatorPods.Items)), + Details: podDetails, + } + } else { + result.Checks["operator_pods"] = CheckStatus{ + Status: "warning", + Message: fmt.Sprintf("%d/%d pods running", runningCount, len(operatorPods.Items)), + Details: podDetails, + } + result.Healthy = false + recommendations = append(recommendations, fmt.Sprintf("Check operator logs: kubectl logs -n %s -l %s", namespace, operatorPodLabel)) + } + } + + // Check 3: Storage pods running + storagePods, err := k.k8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: storagePodLabel, + }) + if err != nil { + result.Checks["storage_pods"] = CheckStatus{ + Status: "error", + Message: fmt.Sprintf("Failed to list storage pods: %v", err), + } + result.Healthy = false + } else if len(storagePods.Items) == 0 { + result.Checks["storage_pods"] = CheckStatus{ + Status: "warning", + Message: "No storage pods found (may be using external storage)", + } + } else { + runningCount := 0 + for _, pod := range storagePods.Items { + if pod.Status.Phase == corev1.PodRunning { + runningCount++ + } + } + if runningCount == len(storagePods.Items) { + result.Checks["storage_pods"] = CheckStatus{ + Status: "ok", + Message: fmt.Sprintf("%d/%d pods running", runningCount, len(storagePods.Items)), + } + } else { + result.Checks["storage_pods"] = CheckStatus{ + Status: "warning", + Message: fmt.Sprintf("%d/%d pods running", runningCount, len(storagePods.Items)), + } + } + } + + // Check 4: VulnerabilityManifests CRD exists + _, err = k.apiExtClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, vulnerabilityManifestsCRD, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + result.Checks["vulnerability_crd"] = CheckStatus{ + Status: "error", + Message: "VulnerabilityManifests CRD not installed - vulnerability scanning may not be enabled", + } + result.Healthy = false + recommendations = append(recommendations, + "Enable vulnerability scanning in Kubescape Helm chart: helm upgrade --install kubescape kubescape/kubescape-operator -n kubescape --set capabilities.vulnerabilityScan=enable") + } else { + result.Checks["vulnerability_crd"] = CheckStatus{ + Status: "error", + Message: fmt.Sprintf("Failed to check CRD: %v", err), + } + result.Healthy = false + } + } else { + result.Checks["vulnerability_crd"] = CheckStatus{ + Status: "ok", + Message: "CRD installed", + } + } + + // Check 5: WorkloadConfigurationScans CRD exists + _, err = k.apiExtClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, workloadConfigurationScansCRD, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + result.Checks["configuration_crd"] = CheckStatus{ + Status: "error", + Message: "WorkloadConfigurationScans CRD not installed - configuration scanning may not be enabled", + } + result.Healthy = false + recommendations = append(recommendations, + "Enable configuration scanning in Kubescape Helm chart: helm upgrade --install kubescape kubescape/kubescape-operator -n kubescape --set capabilities.continuousScan=enable") + } else { + result.Checks["configuration_crd"] = CheckStatus{ + Status: "error", + Message: fmt.Sprintf("Failed to check CRD: %v", err), + } + result.Healthy = false + } + } else { + result.Checks["configuration_crd"] = CheckStatus{ + Status: "ok", + Message: "CRD installed", + } + } + + // Check 6: Vulnerability scan data available + manifests, err := k.spdxClient.VulnerabilityManifests(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1}) + if err != nil { + result.Checks["vulnerability_scan_data"] = CheckStatus{ + Status: "warning", + Message: fmt.Sprintf("Failed to list vulnerability manifests: %v", err), + } + } else if len(manifests.Items) == 0 { + result.Checks["vulnerability_scan_data"] = CheckStatus{ + Status: "warning", + Message: "No vulnerability manifests found - scans may not have completed yet or vulnerability scanning may be disabled", + } + recommendations = append(recommendations, + "If vulnerability scanning is not working, ensure it is enabled: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.vulnerabilityScan=enable") + } else { + // Get actual count + allManifests, _ := k.spdxClient.VulnerabilityManifests(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) + count := 0 + if allManifests != nil { + count = len(allManifests.Items) + } + result.Checks["vulnerability_scan_data"] = CheckStatus{ + Status: "ok", + Message: fmt.Sprintf("%d vulnerability manifests found", count), + } + } + + // Check 7: Configuration scan data available + configScans, err := k.spdxClient.WorkloadConfigurationScans(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1}) + if err != nil { + result.Checks["configuration_scan_data"] = CheckStatus{ + Status: "warning", + Message: fmt.Sprintf("Failed to list configuration scans: %v", err), + } + } else if len(configScans.Items) == 0 { + result.Checks["configuration_scan_data"] = CheckStatus{ + Status: "warning", + Message: "No configuration scans found - scans may not have completed yet or continuous scanning may be disabled", + } + recommendations = append(recommendations, + "If configuration scanning is not working, ensure it is enabled: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.continuousScan=enable") + } else { + // Get actual count + allConfigScans, _ := k.spdxClient.WorkloadConfigurationScans(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) + count := 0 + if allConfigScans != nil { + count = len(allConfigScans.Items) + } + result.Checks["configuration_scan_data"] = CheckStatus{ + Status: "ok", + Message: fmt.Sprintf("%d configuration scans found", count), + } + } + + // Check 8: ApplicationProfiles CRD exists (runtime observability) + _, err = k.apiExtClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, applicationProfilesCRD, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + result.Checks["application_profiles_crd"] = CheckStatus{ + Status: "warning", + Message: "ApplicationProfiles CRD not installed - runtime observability may not be enabled", + } + recommendations = append(recommendations, + "Enable runtime observability for workload behavior analysis: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.runtimeObservability=enable") + } else { + result.Checks["application_profiles_crd"] = CheckStatus{ + Status: "error", + Message: fmt.Sprintf("Failed to check CRD: %v", err), + } + } + } else { + result.Checks["application_profiles_crd"] = CheckStatus{ + Status: "ok", + Message: "CRD installed", + } + + // Check for ApplicationProfile data + profiles, listErr := k.spdxClient.ApplicationProfiles(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1}) + if listErr != nil { + result.Checks["application_profiles_data"] = CheckStatus{ + Status: "warning", + Message: fmt.Sprintf("Failed to list application profiles: %v", listErr), + } + } else if len(profiles.Items) == 0 { + result.Checks["application_profiles_data"] = CheckStatus{ + Status: "warning", + Message: "No application profiles found - runtime learning may not have completed yet", + } + } else { + allProfiles, _ := k.spdxClient.ApplicationProfiles(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) + count := 0 + if allProfiles != nil { + count = len(allProfiles.Items) + } + result.Checks["application_profiles_data"] = CheckStatus{ + Status: "ok", + Message: fmt.Sprintf("%d application profiles found", count), + } + } + } + + // Check 9: NetworkNeighborhoods CRD exists (runtime observability) + _, err = k.apiExtClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, networkNeighborhoodsCRD, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + result.Checks["network_neighborhoods_crd"] = CheckStatus{ + Status: "warning", + Message: "NetworkNeighborhoods CRD not installed - runtime observability may not be enabled", + } + // Only add recommendation if not already added from ApplicationProfiles check + hasRuntimeRecommendation := false + for _, r := range recommendations { + if strings.Contains(r, "runtimeObservability") { + hasRuntimeRecommendation = true + break + } + } + if !hasRuntimeRecommendation { + recommendations = append(recommendations, + "Enable runtime observability for network analysis: helm upgrade kubescape kubescape/kubescape-operator -n kubescape --set capabilities.runtimeObservability=enable") + } + } else { + result.Checks["network_neighborhoods_crd"] = CheckStatus{ + Status: "error", + Message: fmt.Sprintf("Failed to check CRD: %v", err), + } + } + } else { + result.Checks["network_neighborhoods_crd"] = CheckStatus{ + Status: "ok", + Message: "CRD installed", + } + + // Check for NetworkNeighborhood data + neighborhoods, listErr := k.spdxClient.NetworkNeighborhoods(metav1.NamespaceAll).List(ctx, metav1.ListOptions{Limit: 1}) + if listErr != nil { + result.Checks["network_neighborhoods_data"] = CheckStatus{ + Status: "warning", + Message: fmt.Sprintf("Failed to list network neighborhoods: %v", listErr), + } + } else if len(neighborhoods.Items) == 0 { + result.Checks["network_neighborhoods_data"] = CheckStatus{ + Status: "warning", + Message: "No network neighborhoods found - runtime learning may not have completed yet", + } + } else { + allNeighborhoods, _ := k.spdxClient.NetworkNeighborhoods(metav1.NamespaceAll).List(ctx, metav1.ListOptions{}) + count := 0 + if allNeighborhoods != nil { + count = len(allNeighborhoods.Items) + } + result.Checks["network_neighborhoods_data"] = CheckStatus{ + Status: "ok", + Message: fmt.Sprintf("%d network neighborhoods found", count), + } + } + } + + // NOTE: SBOM checks are disabled as SBOM tools are disabled (too large for LLM context) + // Check 10: SBOMSyfts CRD exists - DISABLED + + // Set summary + if result.Healthy { + result.Summary = "Kubescape is fully operational" + } else { + result.Summary = "Kubescape has issues that need attention" + result.Recommendations = recommendations + } + + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(content)), nil +} + +// handleListVulnerabilityManifests lists vulnerability manifests at image and workload levels +func (k *KubescapeTool) handleListVulnerabilityManifests(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if k.initError != nil { + toolErr := errors.NewKubescapeError("list_vulnerability_manifests", k.initError) + return toolErr.ToMCPResult(), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + level := mcp.ParseString(request, "level", "both") + + // Build label selector based on level + labelSelector := "" + switch level { + case "workload": + labelSelector = "kubescape.io/context=filtered" + case "image": + labelSelector = "kubescape.io/context=non-filtered" + } + + // Determine namespace to query + queryNamespace := metav1.NamespaceAll + if namespace != "" { + queryNamespace = namespace + } + + // List manifests + listOpts := metav1.ListOptions{} + if labelSelector != "" { + listOpts.LabelSelector = labelSelector + } + + manifests, err := k.spdxClient.VulnerabilityManifests(queryNamespace).List(ctx, listOpts) + if err != nil { + toolErr := errors.NewKubescapeError("list_vulnerability_manifests", err). + WithContext("namespace", namespace). + WithContext("level", level) + return toolErr.ToMCPResult(), nil + } + + // Build response + vulnerabilityManifests := []map[string]interface{}{} + for _, manifest := range manifests.Items { + isImageLevel := manifest.Annotations[helpersv1.WlidMetadataKey] == "" + manifestMap := map[string]interface{}{ + "namespace": manifest.Namespace, + "manifest_name": manifest.Name, + "image_level": isImageLevel, + "workload_level": !isImageLevel, + "image_id": manifest.Annotations[helpersv1.ImageIDMetadataKey], + "image_tag": manifest.Annotations[helpersv1.ImageTagMetadataKey], + "workload_id": manifest.Annotations[helpersv1.WlidMetadataKey], + "workload_container_name": manifest.Annotations[helpersv1.ContainerNameMetadataKey], + "vulnerability_count": len(manifest.Spec.Payload.Matches), + } + vulnerabilityManifests = append(vulnerabilityManifests, manifestMap) + } + + result := map[string]interface{}{ + "vulnerability_manifests": vulnerabilityManifests, + "total_count": len(vulnerabilityManifests), + } + + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(content)), nil +} + +// handleListVulnerabilitiesInManifest lists all CVEs in a specific manifest +func (k *KubescapeTool) handleListVulnerabilitiesInManifest(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if k.initError != nil { + toolErr := errors.NewKubescapeError("list_vulnerabilities", k.initError) + return toolErr.ToMCPResult(), nil + } + + namespace := mcp.ParseString(request, "namespace", defaultKubescapeNamespace) + manifestName := mcp.ParseString(request, "manifest_name", "") + + if manifestName == "" { + return mcp.NewToolResultError("manifest_name parameter is required"), nil + } + + manifest, err := k.spdxClient.VulnerabilityManifests(namespace).Get(ctx, manifestName, metav1.GetOptions{}) + if err != nil { + toolErr := errors.NewKubescapeError("get_vulnerability_manifest", err). + WithContext("namespace", namespace). + WithContext("manifest_name", manifestName) + return toolErr.ToMCPResult(), nil + } + + // Extract vulnerabilities with summary info + vulnerabilities := []map[string]interface{}{} + severityCounts := map[string]int{ + "Critical": 0, + "High": 0, + "Medium": 0, + "Low": 0, + "Unknown": 0, + } + + for _, match := range manifest.Spec.Payload.Matches { + vuln := match.Vulnerability + severity := string(vuln.Severity) + if _, exists := severityCounts[severity]; exists { + severityCounts[severity]++ + } else { + severityCounts["Unknown"]++ + } + + vulnInfo := map[string]interface{}{ + "id": vuln.ID, + "severity": severity, + "description": truncateString(vuln.Description, 200), + "data_source": vuln.DataSource, + } + + if vuln.Fix.State != "" { + vulnInfo["fix_state"] = vuln.Fix.State + vulnInfo["fix_versions"] = vuln.Fix.Versions + } + + vulnerabilities = append(vulnerabilities, vulnInfo) + } + + result := map[string]interface{}{ + "manifest_name": manifestName, + "namespace": namespace, + "total_count": len(vulnerabilities), + "severity_summary": severityCounts, + "vulnerabilities": vulnerabilities, + } + + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(content)), nil +} + +// handleGetVulnerabilityDetails gets detailed info about a specific CVE in a manifest +func (k *KubescapeTool) handleGetVulnerabilityDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if k.initError != nil { + toolErr := errors.NewKubescapeError("get_vulnerability_details", k.initError) + return toolErr.ToMCPResult(), nil + } + + namespace := mcp.ParseString(request, "namespace", defaultKubescapeNamespace) + manifestName := mcp.ParseString(request, "manifest_name", "") + cveID := mcp.ParseString(request, "cve_id", "") + + if manifestName == "" { + return mcp.NewToolResultError("manifest_name parameter is required"), nil + } + if cveID == "" { + return mcp.NewToolResultError("cve_id parameter is required"), nil + } + + manifest, err := k.spdxClient.VulnerabilityManifests(namespace).Get(ctx, manifestName, metav1.GetOptions{}) + if err != nil { + toolErr := errors.NewKubescapeError("get_vulnerability_manifest", err). + WithContext("namespace", namespace). + WithContext("manifest_name", manifestName) + return toolErr.ToMCPResult(), nil + } + + // Find matching CVE entries + var matches []v1beta1.Match + for _, m := range manifest.Spec.Payload.Matches { + if m.Vulnerability.ID == cveID { + matches = append(matches, m) + } + } + + if len(matches) == 0 { + return mcp.NewToolResultError(fmt.Sprintf("CVE %s not found in manifest %s", cveID, manifestName)), nil + } + + content, err := json.MarshalIndent(matches, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(content)), nil +} + +// handleListConfigurationScans lists configuration security scan results +func (k *KubescapeTool) handleListConfigurationScans(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if k.initError != nil { + toolErr := errors.NewKubescapeError("list_configuration_scans", k.initError) + return toolErr.ToMCPResult(), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + + queryNamespace := metav1.NamespaceAll + if namespace != "" { + queryNamespace = namespace + } + + manifests, err := k.spdxClient.WorkloadConfigurationScans(queryNamespace).List(ctx, metav1.ListOptions{}) + if err != nil { + toolErr := errors.NewKubescapeError("list_configuration_scans", err). + WithContext("namespace", namespace) + return toolErr.ToMCPResult(), nil + } + + configManifests := []map[string]interface{}{} + for _, manifest := range manifests.Items { + item := map[string]interface{}{ + "namespace": manifest.Namespace, + "manifest_name": manifest.Name, + "created_at": manifest.CreationTimestamp.Format(time.RFC3339), + } + configManifests = append(configManifests, item) + } + + result := map[string]interface{}{ + "configuration_scans": configManifests, + "total_count": len(configManifests), + } + + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(content)), nil +} + +// handleGetConfigurationScan gets details of a specific configuration scan +func (k *KubescapeTool) handleGetConfigurationScan(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if k.initError != nil { + toolErr := errors.NewKubescapeError("get_configuration_scan", k.initError) + return toolErr.ToMCPResult(), nil + } + + namespace := mcp.ParseString(request, "namespace", defaultKubescapeNamespace) + manifestName := mcp.ParseString(request, "manifest_name", "") + + if manifestName == "" { + return mcp.NewToolResultError("manifest_name parameter is required"), nil + } + + manifest, err := k.spdxClient.WorkloadConfigurationScans(namespace).Get(ctx, manifestName, metav1.GetOptions{}) + if err != nil { + toolErr := errors.NewKubescapeError("get_configuration_scan", err). + WithContext("namespace", namespace). + WithContext("manifest_name", manifestName) + return toolErr.ToMCPResult(), nil + } + + content, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(content)), nil +} + +// handleListApplicationProfiles lists application profiles showing runtime behavior data +func (k *KubescapeTool) handleListApplicationProfiles(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if k.initError != nil { + toolErr := errors.NewKubescapeError("list_application_profiles", k.initError) + return toolErr.ToMCPResult(), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + + queryNamespace := metav1.NamespaceAll + if namespace != "" { + queryNamespace = namespace + } + + profiles, err := k.spdxClient.ApplicationProfiles(queryNamespace).List(ctx, metav1.ListOptions{}) + if err != nil { + toolErr := errors.NewKubescapeError("list_application_profiles", err). + WithContext("namespace", namespace) + return toolErr.ToMCPResult(), nil + } + + profileList := []map[string]interface{}{} + for _, profile := range profiles.Items { + // Summarize what data is captured per container + containersCount := len(profile.Spec.Containers) + initContainersCount := len(profile.Spec.InitContainers) + ephemeralContainersCount := len(profile.Spec.EphemeralContainers) + + totalExecs := 0 + totalOpens := 0 + totalSyscalls := 0 + totalCapabilities := 0 + totalEndpoints := 0 + + for _, c := range profile.Spec.Containers { + totalExecs += len(c.Execs) + totalOpens += len(c.Opens) + totalSyscalls += len(c.Syscalls) + totalCapabilities += len(c.Capabilities) + totalEndpoints += len(c.Endpoints) + } + + profileMap := map[string]interface{}{ + "namespace": profile.Namespace, + "name": profile.Name, + "containers_count": containersCount, + "init_containers_count": initContainersCount, + "ephemeral_containers_count": ephemeralContainersCount, + "total_execs": totalExecs, + "total_opens": totalOpens, + "total_syscalls": totalSyscalls, + "total_capabilities": totalCapabilities, + "total_endpoints": totalEndpoints, + "created_at": profile.CreationTimestamp.Format(time.RFC3339), + } + profileList = append(profileList, profileMap) + } + + result := map[string]interface{}{ + "application_profiles": profileList, + "total_count": len(profileList), + "description": "ApplicationProfiles capture runtime behavior of workloads including: " + + "executed processes (Execs), file access patterns (Opens), system calls (Syscalls), " + + "Linux capabilities used, and HTTP endpoints accessed. " + + "Use this data to prioritize vulnerabilities - a CVE in an unused package is lower priority than one in an actively running process.", + } + + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(content)), nil +} + +// handleGetApplicationProfile gets detailed runtime behavior for a specific workload +func (k *KubescapeTool) handleGetApplicationProfile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if k.initError != nil { + toolErr := errors.NewKubescapeError("get_application_profile", k.initError) + return toolErr.ToMCPResult(), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + name := mcp.ParseString(request, "name", "") + + if name == "" { + return mcp.NewToolResultError("name parameter is required"), nil + } + if namespace == "" { + return mcp.NewToolResultError("namespace parameter is required"), nil + } + + profile, err := k.spdxClient.ApplicationProfiles(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + toolErr := errors.NewKubescapeError("get_application_profile", err). + WithContext("namespace", namespace). + WithContext("name", name) + return toolErr.ToMCPResult(), nil + } + + // Build detailed response with container behaviors + containers := []map[string]interface{}{} + for _, c := range profile.Spec.Containers { + containerInfo := map[string]interface{}{ + "name": c.Name, + "execs": c.Execs, + "opens": c.Opens, + "syscalls": c.Syscalls, + "capabilities": c.Capabilities, + "endpoints": c.Endpoints, + } + if c.SeccompProfile.Name != "" || c.SeccompProfile.Path != "" { + containerInfo["seccomp_profile"] = c.SeccompProfile + } + containers = append(containers, containerInfo) + } + + initContainers := []map[string]interface{}{} + for _, c := range profile.Spec.InitContainers { + containerInfo := map[string]interface{}{ + "name": c.Name, + "execs": c.Execs, + "opens": c.Opens, + "syscalls": c.Syscalls, + "capabilities": c.Capabilities, + "endpoints": c.Endpoints, + } + initContainers = append(initContainers, containerInfo) + } + + result := map[string]interface{}{ + "namespace": namespace, + "name": name, + "containers": containers, + "init_containers": initContainers, + "annotations": profile.Annotations, + "labels": profile.Labels, + "description": "This ApplicationProfile shows what the workload containers actually execute at runtime. " + + "Execs: processes that run; Opens: files read/written; Syscalls: kernel-level operations; " + + "Capabilities: special Linux privileges; Endpoints: HTTP APIs called. " + + "Compare this with vulnerability findings to prioritize remediation - focus on CVEs affecting actively used components.", + } + + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(content)), nil +} + +// handleListNetworkNeighborhoods lists network communication patterns for workloads +func (k *KubescapeTool) handleListNetworkNeighborhoods(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if k.initError != nil { + toolErr := errors.NewKubescapeError("list_network_neighborhoods", k.initError) + return toolErr.ToMCPResult(), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + + queryNamespace := metav1.NamespaceAll + if namespace != "" { + queryNamespace = namespace + } + + neighborhoods, err := k.spdxClient.NetworkNeighborhoods(queryNamespace).List(ctx, metav1.ListOptions{}) + if err != nil { + toolErr := errors.NewKubescapeError("list_network_neighborhoods", err). + WithContext("namespace", namespace) + return toolErr.ToMCPResult(), nil + } + + neighborhoodList := []map[string]interface{}{} + for _, nn := range neighborhoods.Items { + totalIngress := 0 + totalEgress := 0 + for _, c := range nn.Spec.Containers { + totalIngress += len(c.Ingress) + totalEgress += len(c.Egress) + } + + nnMap := map[string]interface{}{ + "namespace": nn.Namespace, + "name": nn.Name, + "containers_count": len(nn.Spec.Containers), + "total_ingress": totalIngress, + "total_egress": totalEgress, + "created_at": nn.CreationTimestamp.Format(time.RFC3339), + } + neighborhoodList = append(neighborhoodList, nnMap) + } + + result := map[string]interface{}{ + "network_neighborhoods": neighborhoodList, + "total_count": len(neighborhoodList), + "description": "NetworkNeighborhoods capture actual network communication patterns of workloads. " + + "Ingress: connections coming INTO the workload; Egress: connections going OUT from the workload. " + + "Includes DNS names, IP addresses, ports, and protocols. " + + "Use this data to understand attack surface and prioritize network-related security findings.", + } + + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(content)), nil +} + +// handleGetNetworkNeighborhood gets detailed network connections for a specific workload +func (k *KubescapeTool) handleGetNetworkNeighborhood(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if k.initError != nil { + toolErr := errors.NewKubescapeError("get_network_neighborhood", k.initError) + return toolErr.ToMCPResult(), nil + } + + namespace := mcp.ParseString(request, "namespace", "") + name := mcp.ParseString(request, "name", "") + + if name == "" { + return mcp.NewToolResultError("name parameter is required"), nil + } + if namespace == "" { + return mcp.NewToolResultError("namespace parameter is required"), nil + } + + nn, err := k.spdxClient.NetworkNeighborhoods(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + toolErr := errors.NewKubescapeError("get_network_neighborhood", err). + WithContext("namespace", namespace). + WithContext("name", name) + return toolErr.ToMCPResult(), nil + } + + // Build detailed response with container network data + containers := []map[string]interface{}{} + for _, c := range nn.Spec.Containers { + // Format ingress connections + ingressList := []map[string]interface{}{} + for _, ing := range c.Ingress { + ingressInfo := map[string]interface{}{ + "identifier": ing.Identifier, + "type": ing.Type, + } + if ing.DNS != "" { + ingressInfo["dns"] = ing.DNS + } + if len(ing.Ports) > 0 { + ingressInfo["ports"] = ing.Ports + } + if len(ing.IPAddress) > 0 { + ingressInfo["ip_address"] = ing.IPAddress + } + if ing.PodSelector != nil { + ingressInfo["pod_selector"] = ing.PodSelector + } + if ing.NamespaceSelector != nil { + ingressInfo["namespace_selector"] = ing.NamespaceSelector + } + ingressList = append(ingressList, ingressInfo) + } + + // Format egress connections + egressList := []map[string]interface{}{} + for _, egr := range c.Egress { + egressInfo := map[string]interface{}{ + "identifier": egr.Identifier, + "type": egr.Type, + } + if egr.DNS != "" { + egressInfo["dns"] = egr.DNS + } + if len(egr.Ports) > 0 { + egressInfo["ports"] = egr.Ports + } + if len(egr.IPAddress) > 0 { + egressInfo["ip_address"] = egr.IPAddress + } + if egr.PodSelector != nil { + egressInfo["pod_selector"] = egr.PodSelector + } + if egr.NamespaceSelector != nil { + egressInfo["namespace_selector"] = egr.NamespaceSelector + } + egressList = append(egressList, egressInfo) + } + + containerInfo := map[string]interface{}{ + "name": c.Name, + "ingress": ingressList, + "egress": egressList, + } + containers = append(containers, containerInfo) + } + + result := map[string]interface{}{ + "namespace": namespace, + "name": name, + "containers": containers, + "annotations": nn.Annotations, + "labels": nn.Labels, + "description": "This NetworkNeighborhood shows actual network connections observed for this workload. " + + "Ingress connections show what talks TO this workload. Egress connections show what this workload talks TO. " + + "Use this to verify if a workload with a vulnerability is actually exposed to the network.", + } + + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal result: %v", err)), nil + } + + return mcp.NewToolResultText(string(content)), nil +} + +// Helper function to truncate strings +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// RegisterTools registers all Kubescape tools with the MCP server +func RegisterTools(s *server.MCPServer, kubeconfig string, readOnly bool) { + tool := NewKubescapeTool(kubeconfig) + + // Health check tool + s.AddTool(mcp.NewTool("kubescape_check_health", + mcp.WithDescription("Check if Kubescape operator is installed and operational. Verifies namespace, operator pods, storage pods, CRDs, and scan data availability."), + mcp.WithString("namespace", mcp.Description("Namespace to check (default: kubescape)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_check_health", tool.handleCheckHealth))) + + // List vulnerability manifests + s.AddTool(mcp.NewTool("kubescape_list_vulnerability_manifests", + mcp.WithDescription("List vulnerability manifests from Kubescape operator. Returns vulnerability scan results at image or workload level."), + mcp.WithString("namespace", mcp.Description("Filter by namespace (optional, defaults to all namespaces)")), + mcp.WithString("level", mcp.Description("Type of manifests to list: 'image', 'workload', or 'both' (default: both)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_list_vulnerability_manifests", tool.handleListVulnerabilityManifests))) + + // List vulnerabilities in a manifest + s.AddTool(mcp.NewTool("kubescape_list_vulnerabilities", + mcp.WithDescription("List all CVEs/vulnerabilities found in a specific vulnerability manifest. Returns severity summary and vulnerability details."), + mcp.WithString("namespace", mcp.Description("Namespace of the manifest (default: kubescape)")), + mcp.WithString("manifest_name", mcp.Description("Name of the vulnerability manifest"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_list_vulnerabilities", tool.handleListVulnerabilitiesInManifest))) + + // Get detailed vulnerability info + s.AddTool(mcp.NewTool("kubescape_get_vulnerability_details", + mcp.WithDescription("Get detailed information about a specific CVE in a vulnerability manifest, including affected packages and fix information."), + mcp.WithString("namespace", mcp.Description("Namespace of the manifest (default: kubescape)")), + mcp.WithString("manifest_name", mcp.Description("Name of the vulnerability manifest"), mcp.Required()), + mcp.WithString("cve_id", mcp.Description("CVE identifier (e.g., CVE-2023-12345)"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_get_vulnerability_details", tool.handleGetVulnerabilityDetails))) + + // List configuration scans + s.AddTool(mcp.NewTool("kubescape_list_configuration_scans", + mcp.WithDescription("List configuration security scan results from Kubescape operator. Shows workloads that have been scanned for security misconfigurations."), + mcp.WithString("namespace", mcp.Description("Filter by namespace (optional, defaults to all namespaces)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_list_configuration_scans", tool.handleListConfigurationScans))) + + // Get configuration scan details + s.AddTool(mcp.NewTool("kubescape_get_configuration_scan", + mcp.WithDescription("Get detailed configuration security scan results for a specific workload, including failed controls and remediation guidance."), + mcp.WithString("namespace", mcp.Description("Namespace of the scan (default: kubescape)")), + mcp.WithString("manifest_name", mcp.Description("Name of the configuration scan manifest"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_get_configuration_scan", tool.handleGetConfigurationScan))) + + // List application profiles (runtime observability) + s.AddTool(mcp.NewTool("kubescape_list_application_profiles", + mcp.WithDescription("List ApplicationProfiles showing runtime behavior of workloads. These profiles capture: "+ + "executed processes (Execs), file access patterns (Opens), system calls (Syscalls), Linux capabilities used, and HTTP endpoints. "+ + "Use this data to prioritize vulnerability findings - a CVE in an unused package is lower priority than one in an actively running process. "+ + "Requires 'capabilities.runtimeObservability=enable' in Kubescape Helm chart."), + mcp.WithString("namespace", mcp.Description("Filter by namespace (optional, defaults to all namespaces)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_list_application_profiles", tool.handleListApplicationProfiles))) + + // Get application profile details + s.AddTool(mcp.NewTool("kubescape_get_application_profile", + mcp.WithDescription("Get detailed runtime behavior profile for a specific workload. Shows what processes run, what files are accessed, "+ + "what system calls are made, and what capabilities are used per container. "+ + "Compare with CVE findings to prioritize remediation - focus on vulnerabilities affecting actively used components."), + mcp.WithString("namespace", mcp.Description("Namespace of the profile"), mcp.Required()), + mcp.WithString("name", mcp.Description("Name of the application profile"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_get_application_profile", tool.handleGetApplicationProfile))) + + // List network neighborhoods (runtime observability) + s.AddTool(mcp.NewTool("kubescape_list_network_neighborhoods", + mcp.WithDescription("List NetworkNeighborhoods showing actual network communication patterns of workloads. "+ + "These capture: ingress connections (who talks TO the workload), egress connections (who the workload talks TO), "+ + "including DNS names, IP addresses, ports, and protocols. "+ + "Use this to understand attack surface and prioritize network-related security findings. "+ + "Requires 'capabilities.runtimeObservability=enable' in Kubescape Helm chart."), + mcp.WithString("namespace", mcp.Description("Filter by namespace (optional, defaults to all namespaces)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_list_network_neighborhoods", tool.handleListNetworkNeighborhoods))) + + // Get network neighborhood details + s.AddTool(mcp.NewTool("kubescape_get_network_neighborhood", + mcp.WithDescription("Get detailed network connections for a specific workload. Shows all observed ingress and egress traffic "+ + "with DNS names, IPs, ports, and protocols. Use this to verify if a workload with a vulnerability is actually exposed to the network."), + mcp.WithString("namespace", mcp.Description("Namespace of the network neighborhood"), mcp.Required()), + mcp.WithString("name", mcp.Description("Name of the network neighborhood"), mcp.Required()), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("kubescape_get_network_neighborhood", tool.handleGetNetworkNeighborhood))) + + // NOTE: SBOM tools are disabled as they return too much data for LLM context windows. + // SBOMs contain detailed package information that can be very large. + // To enable in the future, uncomment the handlers and tool registrations below. + // + // s.AddTool(mcp.NewTool("kubescape_list_sboms", ...)) + // s.AddTool(mcp.NewTool("kubescape_get_sbom", ...)) +} + +// Interfaces for testing - allows mocking the Kubernetes clients +type KubescapeToolInterface interface { + HandleCheckHealth(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + HandleListVulnerabilityManifests(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + HandleListVulnerabilitiesInManifest(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + HandleGetVulnerabilityDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + HandleListConfigurationScans(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + HandleGetConfigurationScan(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + HandleListApplicationProfiles(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + HandleGetApplicationProfile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + HandleListNetworkNeighborhoods(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + HandleGetNetworkNeighborhood(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + // NOTE: SBOM handlers are disabled as they return too much data for LLM context + // HandleListSBOMs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) + // HandleGetSBOM(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) +} + +// Ensure KubescapeTool implements the interface +var _ KubescapeToolInterface = (*KubescapeTool)(nil) + +// Export handler methods for testing +func (k *KubescapeTool) HandleCheckHealth(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.handleCheckHealth(ctx, request) +} + +func (k *KubescapeTool) HandleListVulnerabilityManifests(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.handleListVulnerabilityManifests(ctx, request) +} + +func (k *KubescapeTool) HandleListVulnerabilitiesInManifest(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.handleListVulnerabilitiesInManifest(ctx, request) +} + +func (k *KubescapeTool) HandleGetVulnerabilityDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.handleGetVulnerabilityDetails(ctx, request) +} + +func (k *KubescapeTool) HandleListConfigurationScans(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.handleListConfigurationScans(ctx, request) +} + +func (k *KubescapeTool) HandleGetConfigurationScan(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.handleGetConfigurationScan(ctx, request) +} + +func (k *KubescapeTool) HandleListApplicationProfiles(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.handleListApplicationProfiles(ctx, request) +} + +func (k *KubescapeTool) HandleGetApplicationProfile(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.handleGetApplicationProfile(ctx, request) +} + +func (k *KubescapeTool) HandleListNetworkNeighborhoods(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.handleListNetworkNeighborhoods(ctx, request) +} + +func (k *KubescapeTool) HandleGetNetworkNeighborhood(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return k.handleGetNetworkNeighborhood(ctx, request) +} + +// NOTE: SBOM handlers are disabled as they return too much data for LLM context +// func (k *KubescapeTool) HandleListSBOMs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// return k.handleListSBOMs(ctx, request) +// } +// +// func (k *KubescapeTool) HandleGetSBOM(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +// return k.handleGetSBOM(ctx, request) +// } diff --git a/pkg/kubescape/kubescape_test.go b/pkg/kubescape/kubescape_test.go new file mode 100644 index 00000000..2b0bcafe --- /dev/null +++ b/pkg/kubescape/kubescape_test.go @@ -0,0 +1,1193 @@ +package kubescape + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + kubescapefake "github.com/kubescape/storage/pkg/generated/clientset/versioned/fake" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsfake "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/fake" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubefake "k8s.io/client-go/kubernetes/fake" +) + +// Helper function to create a CallToolRequest with arguments +func makeRequest(args map[string]interface{}) mcp.CallToolRequest { + request := mcp.CallToolRequest{} + request.Params.Arguments = args + return request +} + +// Helper function to extract text content from MCP result +func getResultText(result *mcp.CallToolResult) string { + if result == nil || len(result.Content) == 0 { + return "" + } + if textContent, ok := result.Content[0].(mcp.TextContent); ok { + return textContent.Text + } + return "" +} + +func TestRegisterTools(t *testing.T) { + s := server.NewMCPServer("test", "1.0.0") + + // Should not panic + assert.NotPanics(t, func() { + RegisterTools(s, "", false) + }) + + // Verify tools are registered by checking the server has tools + // NOTE: SBOM tools are disabled (too large for LLM context), so we expect 10 tools + tools := s.ListTools() + assert.Len(t, tools, 10) + + expectedTools := map[string]bool{ + "kubescape_check_health": false, + "kubescape_list_vulnerability_manifests": false, + "kubescape_list_vulnerabilities": false, + "kubescape_get_vulnerability_details": false, + "kubescape_list_configuration_scans": false, + "kubescape_get_configuration_scan": false, + "kubescape_list_application_profiles": false, + "kubescape_get_application_profile": false, + "kubescape_list_network_neighborhoods": false, + "kubescape_get_network_neighborhood": false, + // NOTE: SBOM tools disabled - too large for LLM context + // "kubescape_list_sboms": false, + // "kubescape_get_sbom": false, + } + + for name := range tools { + if _, exists := expectedTools[name]; exists { + expectedTools[name] = true + } + } + + for name, found := range expectedTools { + assert.True(t, found, "Tool %s not found", name) + } +} + +func TestHandleCheckHealth_AllComponentsHealthy(t *testing.T) { + // Setup fake clients with all components healthy + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset( + // Namespace + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, + // Operator pods + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kubescape-operator-123", + Namespace: "kubescape", + Labels: map[string]string{"app.kubernetes.io/name": "kubescape-operator"}, + }, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + }, + // Storage pods + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "storage-123", + Namespace: "kubescape", + Labels: map[string]string{"app.kubernetes.io/name": "storage"}, + }, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + }, + ) + + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + apiExtClient := apiextensionsfake.NewSimpleClientset( + &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: vulnerabilityManifestsCRD}, + }, + &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: workloadConfigurationScansCRD}, + }, + &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: applicationProfilesCRD}, + }, + &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: networkNeighborhoodsCRD}, + }, + // NOTE: SBOM CRD check is disabled (SBOM tools are too large for LLM context) + ) + + spdxClient := kubescapefake.NewClientset( + &v1beta1.VulnerabilityManifest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-manifest", + Namespace: "kubescape", + }, + }, + &v1beta1.WorkloadConfigurationScan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config-scan", + Namespace: "kubescape", + }, + }, + &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app-profile", + Namespace: "kubescape", + }, + }, + &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-network-neighborhood", + Namespace: "kubescape", + }, + }, + // NOTE: SBOM data check is disabled (SBOM tools are too large for LLM context) + ) + + tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + // Parse the response + var health HealthCheckResult + err = json.Unmarshal([]byte(getResultText(result)), &health) + require.NoError(t, err) + + assert.True(t, health.Healthy) + assert.Equal(t, "ok", health.Checks["namespace"].Status) + assert.Equal(t, "ok", health.Checks["operator_pods"].Status) + assert.Equal(t, "ok", health.Checks["storage_pods"].Status) + assert.Equal(t, "ok", health.Checks["vulnerability_crd"].Status) + assert.Equal(t, "ok", health.Checks["configuration_crd"].Status) + assert.Equal(t, "ok", health.Checks["vulnerability_scan_data"].Status) + assert.Equal(t, "ok", health.Checks["configuration_scan_data"].Status) + assert.Equal(t, "ok", health.Checks["application_profiles_crd"].Status) + assert.Equal(t, "ok", health.Checks["application_profiles_data"].Status) + assert.Equal(t, "ok", health.Checks["network_neighborhoods_crd"].Status) + assert.Equal(t, "ok", health.Checks["network_neighborhoods_data"].Status) + // NOTE: SBOM checks are disabled (SBOM tools are too large for LLM context) + // assert.Equal(t, "ok", health.Checks["sbom_crd"].Status) + // assert.Equal(t, "ok", health.Checks["sbom_data"].Status) + assert.Equal(t, "Kubescape is fully operational", health.Summary) +} + +func TestHandleCheckHealth_NamespaceNotFound(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset() // No namespace + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + apiExtClient := apiextensionsfake.NewSimpleClientset() + spdxClient := kubescapefake.NewClientset() + + tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + var health HealthCheckResult + err = json.Unmarshal([]byte(getResultText(result)), &health) + require.NoError(t, err) + + assert.False(t, health.Healthy) + assert.Equal(t, "error", health.Checks["namespace"].Status) + assert.Contains(t, health.Checks["namespace"].Message, "not found") +} + +func TestHandleCheckHealth_OperatorPodsNotRunning(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, + // No operator pods + ) + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + apiExtClient := apiextensionsfake.NewSimpleClientset() + spdxClient := kubescapefake.NewClientset() + + tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + var health HealthCheckResult + err = json.Unmarshal([]byte(getResultText(result)), &health) + require.NoError(t, err) + + assert.False(t, health.Healthy) + assert.Equal(t, "error", health.Checks["operator_pods"].Status) + assert.Contains(t, health.Checks["operator_pods"].Message, "No operator pods found") + assert.Contains(t, health.Recommendations, "Install Kubescape operator: helm upgrade --install kubescape kubescape/kubescape-operator -n kubescape --create-namespace") +} + +func TestHandleCheckHealth_OperatorPodsUnhealthy(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kubescape-operator-123", + Namespace: "kubescape", + Labels: map[string]string{"app.kubernetes.io/name": "kubescape-operator"}, + }, + Status: corev1.PodStatus{Phase: corev1.PodPending}, // Not running + }, + ) + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + apiExtClient := apiextensionsfake.NewSimpleClientset() + spdxClient := kubescapefake.NewClientset() + + tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + var health HealthCheckResult + err = json.Unmarshal([]byte(getResultText(result)), &health) + require.NoError(t, err) + + assert.False(t, health.Healthy) + assert.Equal(t, "warning", health.Checks["operator_pods"].Status) + assert.Contains(t, health.Checks["operator_pods"].Message, "0/1 pods running") +} + +func TestHandleCheckHealth_VulnerabilityCRDMissing(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, + ) + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + apiExtClient := apiextensionsfake.NewSimpleClientset() // No CRDs + spdxClient := kubescapefake.NewClientset() + + tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + var health HealthCheckResult + err = json.Unmarshal([]byte(getResultText(result)), &health) + require.NoError(t, err) + + assert.False(t, health.Healthy) + assert.Equal(t, "error", health.Checks["vulnerability_crd"].Status) + assert.Contains(t, health.Checks["vulnerability_crd"].Message, "not installed") +} + +func TestHandleCheckHealth_NoScanData(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, + ) + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + apiExtClient := apiextensionsfake.NewSimpleClientset( + &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: vulnerabilityManifestsCRD}, + }, + &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: workloadConfigurationScansCRD}, + }, + ) + spdxClient := kubescapefake.NewClientset() // No vulnerability manifests + + tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + var health HealthCheckResult + err = json.Unmarshal([]byte(getResultText(result)), &health) + require.NoError(t, err) + + // Warning for no scan data + assert.Equal(t, "warning", health.Checks["vulnerability_scan_data"].Status) + assert.Contains(t, health.Checks["vulnerability_scan_data"].Message, "No vulnerability manifests found") +} + +func TestHandleCheckHealth_RuntimeObservabilityCRDsMissing(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kubescape"}}, + ) + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + apiExtClient := apiextensionsfake.NewSimpleClientset( + &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: vulnerabilityManifestsCRD}, + }, + &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: workloadConfigurationScansCRD}, + }, + // No runtime observability CRDs (applicationprofiles, networkneighborhoods) + ) + spdxClient := kubescapefake.NewClientset() + + tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + var health HealthCheckResult + err = json.Unmarshal([]byte(getResultText(result)), &health) + require.NoError(t, err) + + // Warning for missing runtime observability CRDs + assert.Equal(t, "warning", health.Checks["application_profiles_crd"].Status) + assert.Contains(t, health.Checks["application_profiles_crd"].Message, "not installed") + assert.Equal(t, "warning", health.Checks["network_neighborhoods_crd"].Status) + assert.Contains(t, health.Checks["network_neighborhoods_crd"].Message, "not installed") + + // Should have recommendation to enable runtime observability + foundRuntimeRecommendation := false + for _, r := range health.Recommendations { + if contains(r, "runtimeObservability") { + foundRuntimeRecommendation = true + break + } + } + assert.True(t, foundRuntimeRecommendation, "Expected recommendation to enable runtimeObservability") +} + +// Helper function for test +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestHandleCheckHealth_CustomNamespace(t *testing.T) { + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + k8sClient := kubefake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "custom-ns"}}, + ) + //nolint:staticcheck // NewSimpleClientset is deprecated but NewClientset requires generated apply configs + apiExtClient := apiextensionsfake.NewSimpleClientset() + spdxClient := kubescapefake.NewClientset() + + tool := NewKubescapeToolWithClients(k8sClient, apiExtClient, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleCheckHealth(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "custom-ns", + })) + require.NoError(t, err) + require.NotNil(t, result) + + var health HealthCheckResult + err = json.Unmarshal([]byte(getResultText(result)), &health) + require.NoError(t, err) + + assert.Equal(t, "ok", health.Checks["namespace"].Status) + assert.Contains(t, health.Checks["namespace"].Message, "custom-ns") +} + +func TestHandleCheckHealth_InitError(t *testing.T) { + tool := NewKubescapeToolWithError(errors.New("failed to connect")) + + result, err := tool.HandleCheckHealth(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) +} + +func TestHandleListVulnerabilityManifests_Success(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.VulnerabilityManifest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "manifest-1", + Namespace: "default", + Annotations: map[string]string{ + "kubescape.io/image-id": "sha256:abc123", + "kubescape.io/image-tag": "nginx:1.19", + }, + }, + Spec: v1beta1.VulnerabilityManifestSpec{ + Payload: v1beta1.GrypeDocument{ + Matches: []v1beta1.Match{ + {Vulnerability: v1beta1.Vulnerability{VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ID: "CVE-2021-1234"}}}, + }, + }, + }, + }, + &v1beta1.VulnerabilityManifest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "manifest-2", + Namespace: "kubescape", + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(2), response["total_count"]) + manifests := response["vulnerability_manifests"].([]interface{}) + assert.Len(t, manifests, 2) +} + +func TestHandleListVulnerabilityManifests_FilterByNamespace(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.VulnerabilityManifest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "manifest-1", + Namespace: "default", + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "default", + })) + require.NoError(t, err) + require.NotNil(t, result) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(1), response["total_count"]) +} + +func TestHandleListVulnerabilityManifests_EmptyResults(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(0), response["total_count"]) +} + +func TestHandleListVulnerabilityManifests_InitError(t *testing.T) { + tool := NewKubescapeToolWithError(errors.New("failed to connect")) + + result, err := tool.HandleListVulnerabilityManifests(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) +} + +func TestHandleListVulnerabilitiesInManifest_Success(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.VulnerabilityManifest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-manifest", + Namespace: "kubescape", + }, + Spec: v1beta1.VulnerabilityManifestSpec{ + Payload: v1beta1.GrypeDocument{ + Matches: []v1beta1.Match{ + { + Vulnerability: v1beta1.Vulnerability{ + VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ + ID: "CVE-2021-1234", + Severity: "Critical", + Description: "Test vulnerability", + }, + }, + }, + { + Vulnerability: v1beta1.Vulnerability{ + VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ + ID: "CVE-2021-5678", + Severity: "High", + }, + }, + }, + }, + }, + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), makeRequest(map[string]interface{}{ + "manifest_name": "test-manifest", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(2), response["total_count"]) + severitySummary := response["severity_summary"].(map[string]interface{}) + assert.Equal(t, float64(1), severitySummary["Critical"]) + assert.Equal(t, float64(1), severitySummary["High"]) +} + +func TestHandleListVulnerabilitiesInManifest_MissingManifestName(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "manifest_name parameter is required") +} + +func TestHandleListVulnerabilitiesInManifest_ManifestNotFound(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListVulnerabilitiesInManifest(context.Background(), makeRequest(map[string]interface{}{ + "manifest_name": "nonexistent", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) +} + +func TestHandleGetVulnerabilityDetails_Success(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.VulnerabilityManifest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-manifest", + Namespace: "kubescape", + }, + Spec: v1beta1.VulnerabilityManifestSpec{ + Payload: v1beta1.GrypeDocument{ + Matches: []v1beta1.Match{ + { + Vulnerability: v1beta1.Vulnerability{ + VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ + ID: "CVE-2021-1234", + Severity: "Critical", + Description: "Test vulnerability", + }, + Fix: v1beta1.Fix{ + State: "fixed", + Versions: []string{"1.2.3"}, + }, + }, + }, + }, + }, + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ + "manifest_name": "test-manifest", + "cve_id": "CVE-2021-1234", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + var matches []v1beta1.Match + err = json.Unmarshal([]byte(getResultText(result)), &matches) + require.NoError(t, err) + + assert.Len(t, matches, 1) + assert.Equal(t, "CVE-2021-1234", matches[0].Vulnerability.ID) +} + +func TestHandleGetVulnerabilityDetails_MissingManifestName(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ + "cve_id": "CVE-2021-1234", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "manifest_name parameter is required") +} + +func TestHandleGetVulnerabilityDetails_MissingCveId(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ + "manifest_name": "test-manifest", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "cve_id parameter is required") +} + +func TestHandleGetVulnerabilityDetails_CveNotFound(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.VulnerabilityManifest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-manifest", + Namespace: "kubescape", + }, + Spec: v1beta1.VulnerabilityManifestSpec{ + Payload: v1beta1.GrypeDocument{ + Matches: []v1beta1.Match{}, + }, + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetVulnerabilityDetails(context.Background(), makeRequest(map[string]interface{}{ + "manifest_name": "test-manifest", + "cve_id": "CVE-2021-1234", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "CVE CVE-2021-1234 not found") +} + +func TestHandleListConfigurationScans_Success(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.WorkloadConfigurationScan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "scan-1", + Namespace: "default", + }, + }, + &v1beta1.WorkloadConfigurationScan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "scan-2", + Namespace: "kubescape", + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListConfigurationScans(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(2), response["total_count"]) +} + +func TestHandleListConfigurationScans_FilterByNamespace(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.WorkloadConfigurationScan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "scan-1", + Namespace: "default", + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListConfigurationScans(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "default", + })) + require.NoError(t, err) + require.NotNil(t, result) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(1), response["total_count"]) +} + +func TestHandleListConfigurationScans_EmptyResults(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListConfigurationScans(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(0), response["total_count"]) +} + +func TestHandleGetConfigurationScan_Success(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.WorkloadConfigurationScan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-scan", + Namespace: "kubescape", + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetConfigurationScan(context.Background(), makeRequest(map[string]interface{}{ + "manifest_name": "test-scan", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) +} + +func TestHandleGetConfigurationScan_MissingManifestName(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetConfigurationScan(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "manifest_name parameter is required") +} + +func TestHandleGetConfigurationScan_NotFound(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetConfigurationScan(context.Background(), makeRequest(map[string]interface{}{ + "manifest_name": "nonexistent", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) +} + +func TestTruncateString(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + expected string + }{ + {"short string", "hello", 10, "hello"}, + {"exact length", "hello", 5, "hello"}, + {"truncated", "hello world", 5, "hello..."}, + {"empty string", "", 10, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := truncateString(tt.input, tt.maxLen) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNilArgumentsHandling(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + // Test with nil arguments map - should use defaults + request := mcp.CallToolRequest{} + request.Params.Arguments = nil + + result, err := tool.HandleListVulnerabilityManifests(context.Background(), request) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) +} + +// Tests for ApplicationProfile handlers + +func TestHandleListApplicationProfiles_Success(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "profile-1", + Namespace: "default", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "container-1", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/bash"}, + }, + Opens: []v1beta1.OpenCalls{ + {Path: "/etc/passwd"}, + }, + Syscalls: []string{"read", "write"}, + }, + }, + }, + }, + &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "profile-2", + Namespace: "kubescape", + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(2), response["total_count"]) + assert.Contains(t, response["description"], "ApplicationProfiles capture runtime behavior") + profiles := response["application_profiles"].([]interface{}) + assert.Len(t, profiles, 2) +} + +func TestHandleListApplicationProfiles_FilterByNamespace(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "profile-1", + Namespace: "default", + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "default", + })) + require.NoError(t, err) + require.NotNil(t, result) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(1), response["total_count"]) +} + +func TestHandleListApplicationProfiles_EmptyResults(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(0), response["total_count"]) +} + +func TestHandleListApplicationProfiles_InitError(t *testing.T) { + tool := NewKubescapeToolWithError(errors.New("failed to connect")) + + result, err := tool.HandleListApplicationProfiles(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) +} + +func TestHandleGetApplicationProfile_Success(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-profile", + Namespace: "default", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "container-1", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/bash"}, + }, + Opens: []v1beta1.OpenCalls{ + {Path: "/etc/passwd"}, + }, + Syscalls: []string{"read", "write"}, + Capabilities: []string{"NET_ADMIN"}, + }, + }, + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "default", + "name": "test-profile", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, "default", response["namespace"]) + assert.Equal(t, "test-profile", response["name"]) + assert.Contains(t, response["description"], "ApplicationProfile shows what the workload containers actually execute") +} + +func TestHandleGetApplicationProfile_MissingName(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "default", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "name parameter is required") +} + +func TestHandleGetApplicationProfile_MissingNamespace(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ + "name": "test-profile", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "namespace parameter is required") +} + +func TestHandleGetApplicationProfile_NotFound(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetApplicationProfile(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "default", + "name": "nonexistent", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) +} + +// Tests for NetworkNeighborhood handlers + +func TestHandleListNetworkNeighborhoods_Success(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nn-1", + Namespace: "default", + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + Containers: []v1beta1.NetworkNeighborhoodContainer{ + { + Name: "container-1", + Ingress: []v1beta1.NetworkNeighbor{ + {Identifier: "pod-1", Type: "internal"}, + }, + Egress: []v1beta1.NetworkNeighbor{ + {Identifier: "api.example.com", Type: "external", DNS: "api.example.com"}, + }, + }, + }, + }, + }, + &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nn-2", + Namespace: "kubescape", + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(2), response["total_count"]) + assert.Contains(t, response["description"], "NetworkNeighborhoods capture actual network communication patterns") + neighborhoods := response["network_neighborhoods"].([]interface{}) + assert.Len(t, neighborhoods, 2) +} + +func TestHandleListNetworkNeighborhoods_FilterByNamespace(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nn-1", + Namespace: "default", + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "default", + })) + require.NoError(t, err) + require.NotNil(t, result) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(1), response["total_count"]) +} + +func TestHandleListNetworkNeighborhoods_EmptyResults(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, float64(0), response["total_count"]) +} + +func TestHandleListNetworkNeighborhoods_InitError(t *testing.T) { + tool := NewKubescapeToolWithError(errors.New("failed to connect")) + + result, err := tool.HandleListNetworkNeighborhoods(context.Background(), makeRequest(nil)) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) +} + +func TestHandleGetNetworkNeighborhood_Success(t *testing.T) { + spdxClient := kubescapefake.NewClientset( + &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nn", + Namespace: "default", + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + Containers: []v1beta1.NetworkNeighborhoodContainer{ + { + Name: "container-1", + Ingress: []v1beta1.NetworkNeighbor{ + {Identifier: "pod-1", Type: "internal"}, + }, + Egress: []v1beta1.NetworkNeighbor{ + {Identifier: "api.example.com", Type: "external", DNS: "api.example.com"}, + }, + }, + }, + }, + }, + ) + + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "default", + "name": "test-nn", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + + var response map[string]interface{} + err = json.Unmarshal([]byte(getResultText(result)), &response) + require.NoError(t, err) + + assert.Equal(t, "default", response["namespace"]) + assert.Equal(t, "test-nn", response["name"]) + assert.Contains(t, response["description"], "NetworkNeighborhood shows actual network connections") +} + +func TestHandleGetNetworkNeighborhood_MissingName(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "default", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "name parameter is required") +} + +func TestHandleGetNetworkNeighborhood_MissingNamespace(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ + "name": "test-nn", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "namespace parameter is required") +} + +func TestHandleGetNetworkNeighborhood_NotFound(t *testing.T) { + spdxClient := kubescapefake.NewClientset() + tool := NewKubescapeToolWithClients(nil, nil, spdxClient.SpdxV1beta1()) + + result, err := tool.HandleGetNetworkNeighborhood(context.Background(), makeRequest(map[string]interface{}{ + "namespace": "default", + "name": "nonexistent", + })) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) +} + +// NOTE: SBOM tests are disabled as SBOM tools are too large for LLM context windows. +// The handlers still exist in the code but are not registered or exported. +// +// Tests for SBOM handlers - DISABLED +// +// func TestHandleListSBOMs_Success(t *testing.T) { ... } +// func TestHandleListSBOMs_FilterByNamespace(t *testing.T) { ... } +// func TestHandleListSBOMs_EmptyResults(t *testing.T) { ... } +// func TestHandleListSBOMs_InitError(t *testing.T) { ... } +// func TestHandleGetSBOM_Success(t *testing.T) { ... } +// func TestHandleGetSBOM_MissingName(t *testing.T) { ... } +// func TestHandleGetSBOM_MissingNamespace(t *testing.T) { ... } +// func TestHandleGetSBOM_NotFound(t *testing.T) { ... } diff --git a/pkg/kubescape/testing.go b/pkg/kubescape/testing.go new file mode 100644 index 00000000..093e3711 --- /dev/null +++ b/pkg/kubescape/testing.go @@ -0,0 +1,28 @@ +package kubescape + +import ( + spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" + apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/client-go/kubernetes" +) + +// NewKubescapeToolWithClients creates a KubescapeTool with pre-configured clients for testing +func NewKubescapeToolWithClients( + k8sClient kubernetes.Interface, + apiExtClient apiextensionsclientset.Interface, + spdxClient spdxv1beta1.SpdxV1beta1Interface, +) *KubescapeTool { + return &KubescapeTool{ + k8sClient: k8sClient, + apiExtClient: apiExtClient, + spdxClient: spdxClient, + initError: nil, + } +} + +// NewKubescapeToolWithError creates a KubescapeTool with an initialization error for testing error paths +func NewKubescapeToolWithError(err error) *KubescapeTool { + return &KubescapeTool{ + initError: err, + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go deleted file mode 100644 index 062997de..00000000 --- a/pkg/logger/logger.go +++ /dev/null @@ -1,58 +0,0 @@ -package logger - -import ( - "github.com/go-logr/logr" - "github.com/go-logr/stdr" -) - -var globalLogger logr.Logger - -// Init initializes the global logger with appropriate configuration -func Init() { - // Set log level from environment variable (not directly supported by stdr, but can be extended) - // For now, just use stdr with default settings - globalLogger = stdr.New(nil) -} - -// Get returns the global logger instance -func Get() logr.Logger { - if globalLogger.GetSink() == nil { - Init() - } - return globalLogger -} - -// LogExecCommand logs information about an exec command being executed -func LogExecCommand(command string, args []string, caller string) { - logger := Get() - logger.Info("executing command", - "command", command, - "args", args, - "caller", caller, - ) -} - -// LogExecCommandResult logs the result of an exec command -func LogExecCommandResult(command string, args []string, output string, err error, duration float64, caller string) { - logger := Get() - - if err != nil { - logger.Error(err, "command execution failed", - "command", command, - "args", args, - "duration_seconds", duration, - "caller", caller, - ) - } else { - logger.Info("command execution successful", - "command", command, - "args", args, - "output", output, - "duration_seconds", duration, - "caller", caller, - ) - } -} - -// Sync is a no-op for logr/stdr -func Sync() {} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go deleted file mode 100644 index 24903d07..00000000 --- a/pkg/logger/logger_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package logger - -import ( - "os" - "testing" - - "github.com/go-logr/logr" - "github.com/stretchr/testify/assert" -) - -func TestInit(t *testing.T) { - // Test initialization - Init() - assert.NotNil(t, globalLogger) -} - -func TestGet(t *testing.T) { - // Reset global logger - globalLogger = logr.Logger{} - - // Test Get without Init - logger := Get() - assert.NotNil(t, logger) - assert.NotNil(t, globalLogger) -} - -func TestLogExecCommand(t *testing.T) { - // Just test that it does not panic and logs - assert.NotPanics(t, func() { - LogExecCommand("test-command", []string{"arg1", "arg2"}, "test.go:123") - }) -} - -func TestLogExecCommandResult(t *testing.T) { - // Test successful command - assert.NotPanics(t, func() { - LogExecCommandResult("test-command", []string{"arg1"}, "success output", nil, 1.5, "test.go:123") - }) - // Test failed command - assert.NotPanics(t, func() { - LogExecCommandResult("test-command", []string{"arg1"}, "error output", assert.AnError, 0.5, "test.go:123") - }) -} - -func TestEnvironmentVariables(t *testing.T) { - // Test log level from environment (no-op for stdr) - os.Setenv("KAGENT_LOG_LEVEL", "debug") - defer os.Unsetenv("KAGENT_LOG_LEVEL") - - // Reset global logger - globalLogger = logr.Logger{} - - // Initialize with environment variable - Init() - - // Just check logger is set - assert.NotNil(t, globalLogger) -} - -func TestDevelopmentMode(t *testing.T) { - // Test development mode (no-op for stdr) - os.Setenv("KAGENT_ENV", "development") - defer os.Unsetenv("KAGENT_ENV") - - // Reset global logger - globalLogger = logr.Logger{} - - // Initialize in development mode - Init() - - // In development mode, the logger should be configured (no panic) - assert.NotNil(t, globalLogger) -} - -func TestSync(t *testing.T) { - // Test Sync function - Init() - - // Sync should not panic - assert.NotPanics(t, func() { - Sync() - }) -} diff --git a/pkg/prometheus/prometheus.go b/pkg/prometheus/prometheus.go index 607fd134..c77e23d4 100644 --- a/pkg/prometheus/prometheus.go +++ b/pkg/prometheus/prometheus.go @@ -9,6 +9,9 @@ import ( "net/url" "time" + "github.com/kagent-dev/tools/internal/errors" + "github.com/kagent-dev/tools/internal/security" + "github.com/kagent-dev/tools/internal/telemetry" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -33,6 +36,16 @@ func handlePrometheusQueryTool(ctx context.Context, request mcp.CallToolRequest) return mcp.NewToolResultError("query parameter is required"), nil } + // Validate prometheus URL + if err := security.ValidateURL(prometheusURL); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil + } + + // Validate PromQL query + if err := security.ValidatePromQLQuery(query); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid PromQL query: %v", err)), nil + } + // Make request to Prometheus API apiURL := fmt.Sprintf("%s/api/v1/query", prometheusURL) params := url.Values{} @@ -42,19 +55,40 @@ func handlePrometheusQueryTool(ctx context.Context, request mcp.CallToolRequest) fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode()) client := getHTTPClient(ctx) - resp, err := client.Get(fullURL) + req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) if err != nil { - return mcp.NewToolResultError("failed to query Prometheus: " + err.Error()), nil + toolErr := errors.NewPrometheusError("create_request", err). + WithContext("prometheus_url", prometheusURL). + WithContext("query", query) + return toolErr.ToMCPResult(), nil + } + + resp, err := client.Do(req) + if err != nil { + toolErr := errors.NewPrometheusError("query_execution", err). + WithContext("prometheus_url", prometheusURL). + WithContext("query", query). + WithContext("api_url", apiURL) + return toolErr.ToMCPResult(), nil } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return mcp.NewToolResultError("failed to read response: " + err.Error()), nil + toolErr := errors.NewPrometheusError("read_response", err). + WithContext("prometheus_url", prometheusURL). + WithContext("query", query). + WithContext("status_code", resp.StatusCode) + return toolErr.ToMCPResult(), nil } if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("Prometheus API error (%d): %s", resp.StatusCode, string(body))), nil + toolErr := errors.NewPrometheusError("api_error", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))). + WithContext("prometheus_url", prometheusURL). + WithContext("query", query). + WithContext("status_code", resp.StatusCode). + WithContext("response_body", string(body)) + return toolErr.ToMCPResult(), nil } // Parse the JSON response to pretty-print it @@ -82,6 +116,33 @@ func handlePrometheusRangeQueryTool(ctx context.Context, request mcp.CallToolReq return mcp.NewToolResultError("query parameter is required"), nil } + // Validate prometheus URL + if err := security.ValidateURL(prometheusURL); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil + } + + // Validate PromQL query + if err := security.ValidatePromQLQuery(query); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid PromQL query: %v", err)), nil + } + + // Validate time parameters if provided + if start != "" { + if err := security.ValidateCommandInput(start); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid start time: %v", err)), nil + } + } + if end != "" { + if err := security.ValidateCommandInput(end); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid end time: %v", err)), nil + } + } + if step != "" { + if err := security.ValidateCommandInput(step); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid step parameter: %v", err)), nil + } + } + // Use default time range if not specified if start == "" { start = fmt.Sprintf("%d", time.Now().Add(-1*time.Hour).Unix()) @@ -101,7 +162,12 @@ func handlePrometheusRangeQueryTool(ctx context.Context, request mcp.CallToolReq fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode()) client := getHTTPClient(ctx) - resp, err := client.Get(fullURL) + req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) + if err != nil { + return mcp.NewToolResultError("failed to create request: " + err.Error()), nil + } + + resp, err := client.Do(req) if err != nil { return mcp.NewToolResultError("failed to query Prometheus: " + err.Error()), nil } @@ -133,23 +199,48 @@ func handlePrometheusRangeQueryTool(ctx context.Context, request mcp.CallToolReq func handlePrometheusLabelsQueryTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { prometheusURL := mcp.ParseString(request, "prometheus_url", "http://localhost:9090") + // Validate prometheus URL + if err := security.ValidateURL(prometheusURL); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil + } + // Make request to Prometheus API for labels apiURL := fmt.Sprintf("%s/api/v1/labels", prometheusURL) client := getHTTPClient(ctx) - resp, err := client.Get(apiURL) + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { - return mcp.NewToolResultError("failed to query Prometheus: " + err.Error()), nil + toolErr := errors.NewPrometheusError("create_request", err). + WithContext("prometheus_url", prometheusURL). + WithContext("api_url", apiURL) + return toolErr.ToMCPResult(), nil + } + + resp, err := client.Do(req) + if err != nil { + toolErr := errors.NewPrometheusError("query_execution", err). + WithContext("prometheus_url", prometheusURL). + WithContext("api_url", apiURL) + return toolErr.ToMCPResult(), nil } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return mcp.NewToolResultError("failed to read response: " + err.Error()), nil + toolErr := errors.NewPrometheusError("read_response", err). + WithContext("prometheus_url", prometheusURL). + WithContext("api_url", apiURL). + WithContext("status_code", resp.StatusCode) + return toolErr.ToMCPResult(), nil } if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("Prometheus API error (%d): %s", resp.StatusCode, string(body))), nil + toolErr := errors.NewPrometheusError("api_error", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))). + WithContext("prometheus_url", prometheusURL). + WithContext("api_url", apiURL). + WithContext("status_code", resp.StatusCode). + WithContext("response_body", string(body)) + return toolErr.ToMCPResult(), nil } // Parse the JSON response to pretty-print it @@ -169,11 +260,21 @@ func handlePrometheusLabelsQueryTool(ctx context.Context, request mcp.CallToolRe func handlePrometheusTargetsQueryTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { prometheusURL := mcp.ParseString(request, "prometheus_url", "http://localhost:9090") + // Validate prometheus URL + if err := security.ValidateURL(prometheusURL); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid Prometheus URL: %v", err)), nil + } + // Make request to Prometheus API for targets apiURL := fmt.Sprintf("%s/api/v1/targets", prometheusURL) client := getHTTPClient(ctx) - resp, err := client.Get(apiURL) + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return mcp.NewToolResultError("failed to create request: " + err.Error()), nil + } + + resp, err := client.Do(req) if err != nil { return mcp.NewToolResultError("failed to query Prometheus: " + err.Error()), nil } @@ -202,12 +303,12 @@ func handlePrometheusTargetsQueryTool(ctx context.Context, request mcp.CallToolR return mcp.NewToolResultText(string(prettyJSON)), nil } -func RegisterPrometheusTools(s *server.MCPServer) { +func RegisterTools(s *server.MCPServer, readOnly bool) { s.AddTool(mcp.NewTool("prometheus_query_tool", mcp.WithDescription("Execute a PromQL query against Prometheus"), mcp.WithString("query", mcp.Description("PromQL query to execute"), mcp.Required()), mcp.WithString("prometheus_url", mcp.Description("Prometheus server URL (default: http://localhost:9090)")), - ), handlePrometheusQueryTool) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("prometheus_query_tool", handlePrometheusQueryTool))) s.AddTool(mcp.NewTool("prometheus_query_range_tool", mcp.WithDescription("Execute a PromQL range query against Prometheus"), @@ -216,20 +317,20 @@ func RegisterPrometheusTools(s *server.MCPServer) { mcp.WithString("end", mcp.Description("End time (Unix timestamp or relative time)")), mcp.WithString("step", mcp.Description("Query resolution step (default: 15s)")), mcp.WithString("prometheus_url", mcp.Description("Prometheus server URL (default: http://localhost:9090)")), - ), handlePrometheusRangeQueryTool) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("prometheus_query_range_tool", handlePrometheusRangeQueryTool))) s.AddTool(mcp.NewTool("prometheus_label_names_tool", mcp.WithDescription("Get all available labels from Prometheus"), mcp.WithString("prometheus_url", mcp.Description("Prometheus server URL (default: http://localhost:9090)")), - ), handlePrometheusLabelsQueryTool) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("prometheus_label_names_tool", handlePrometheusLabelsQueryTool))) s.AddTool(mcp.NewTool("prometheus_targets_tool", mcp.WithDescription("Get all Prometheus targets and their status"), mcp.WithString("prometheus_url", mcp.Description("Prometheus server URL (default: http://localhost:9090)")), - ), handlePrometheusTargetsQueryTool) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("prometheus_targets_tool", handlePrometheusTargetsQueryTool))) s.AddTool(mcp.NewTool("prometheus_promql_tool", mcp.WithDescription("Generate a PromQL query"), mcp.WithString("query_description", mcp.Description("A string describing the query to generate"), mcp.Required()), - ), handlePromql) + ), telemetry.AdaptToolHandler(telemetry.WithTracing("prometheus_promql_tool", handlePromql))) } diff --git a/pkg/prometheus/prometheus_test.go b/pkg/prometheus/prometheus_test.go index d0246ac3..647d1f39 100644 --- a/pkg/prometheus/prometheus_test.go +++ b/pkg/prometheus/prometheus_test.go @@ -1,7 +1,14 @@ package prometheus import ( + "context" + "io" "net/http" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" ) // mockRoundTripper is used to mock HTTP responses for testing @@ -25,3 +32,478 @@ func newTestClient(response *http.Response, err error) *http.Client { }, } } + +// Helper function to extract text content from MCP result +func getResultText(result *mcp.CallToolResult) string { + if result == nil || len(result.Content) == 0 { + return "" + } + if textContent, ok := result.Content[0].(mcp.TextContent); ok { + return textContent.Text + } + return "" +} + +// Helper function to create a mock HTTP response +func createMockResponse(statusCode int, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} + +// Helper function to create context with mock HTTP client +func contextWithMockClient(client *http.Client) context.Context { + return context.WithValue(context.Background(), clientKey{}, client) +} + +func TestHandlePrometheusQueryTool(t *testing.T) { + t.Run("successful query", func(t *testing.T) { + mockResponse := `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": {"__name__": "up", "instance": "localhost:9090"}, + "value": [1609459200, "1"] + } + ] + } + }` + + client := newTestClient(createMockResponse(200, mockResponse), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up", + "prometheus_url": "http://localhost:9090", + } + + result, err := handlePrometheusQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + content := getResultText(result) + assert.Contains(t, content, "success") + assert.Contains(t, content, "up") + }) + + t.Run("missing query parameter", func(t *testing.T) { + ctx := context.Background() + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "prometheus_url": "http://localhost:9090", + } + + result, err := handlePrometheusQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "query parameter is required") + }) + + t.Run("HTTP error", func(t *testing.T) { + client := newTestClient(nil, assert.AnError) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up", + } + + result, err := handlePrometheusQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "**Prometheus Error**") + }) + + t.Run("HTTP 500 error", func(t *testing.T) { + client := newTestClient(createMockResponse(500, "Internal Server Error"), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up", + } + + result, err := handlePrometheusQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "**Prometheus Error**") + }) + + t.Run("malformed JSON response", func(t *testing.T) { + client := newTestClient(createMockResponse(200, "invalid json {"), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up", + } + + result, err := handlePrometheusQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + // Should return raw response when JSON parsing fails + assert.Contains(t, getResultText(result), "invalid json") + }) + + t.Run("default prometheus URL", func(t *testing.T) { + mockResponse := `{"status": "success", "data": {"result": []}}` + client := newTestClient(createMockResponse(200, mockResponse), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up", + } + + result, err := handlePrometheusQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + }) +} + +func TestHandlePrometheusRangeQueryTool(t *testing.T) { + t.Run("successful range query", func(t *testing.T) { + mockResponse := `{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": {"__name__": "up"}, + "values": [[1609459200, "1"], [1609459260, "1"]] + } + ] + } + }` + + client := newTestClient(createMockResponse(200, mockResponse), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up", + "start": "1609459200", + "end": "1609459260", + "step": "60s", + } + + result, err := handlePrometheusRangeQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + content := getResultText(result) + assert.Contains(t, content, "matrix") + assert.Contains(t, content, "values") + }) + + t.Run("missing query parameter", func(t *testing.T) { + ctx := context.Background() + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{} + + result, err := handlePrometheusRangeQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "query parameter is required") + }) + + t.Run("default time range and step", func(t *testing.T) { + mockResponse := `{"status": "success", "data": {"result": []}}` + client := newTestClient(createMockResponse(200, mockResponse), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up", + } + + result, err := handlePrometheusRangeQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + }) +} + +func TestHandlePrometheusLabelsQueryTool(t *testing.T) { + t.Run("successful labels query", func(t *testing.T) { + mockResponse := `{ + "status": "success", + "data": ["__name__", "instance", "job"] + }` + + client := newTestClient(createMockResponse(200, mockResponse), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{} + + result, err := handlePrometheusLabelsQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + content := getResultText(result) + assert.Contains(t, content, "__name__") + assert.Contains(t, content, "instance") + assert.Contains(t, content, "job") + }) + + t.Run("HTTP error", func(t *testing.T) { + client := newTestClient(nil, assert.AnError) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{} + + result, err := handlePrometheusLabelsQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "**Prometheus Error**") + }) + + t.Run("custom prometheus URL", func(t *testing.T) { + mockResponse := `{"status": "success", "data": []}` + client := newTestClient(createMockResponse(200, mockResponse), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "prometheus_url": "http://custom:9090", + } + + result, err := handlePrometheusLabelsQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + }) +} + +func TestHandlePrometheusTargetsQueryTool(t *testing.T) { + t.Run("successful targets query", func(t *testing.T) { + mockResponse := `{ + "status": "success", + "data": { + "activeTargets": [ + { + "discoveredLabels": {"__address__": "localhost:9090"}, + "labels": {"instance": "localhost:9090", "job": "prometheus"}, + "scrapePool": "prometheus", + "scrapeUrl": "http://localhost:9090/metrics", + "health": "up" + } + ] + } + }` + + client := newTestClient(createMockResponse(200, mockResponse), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{} + + result, err := handlePrometheusTargetsQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + content := getResultText(result) + assert.Contains(t, content, "activeTargets") + assert.Contains(t, content, "localhost:9090") + assert.Contains(t, content, "up") + }) + + t.Run("HTTP 404 error", func(t *testing.T) { + client := newTestClient(createMockResponse(404, "Not Found"), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{} + + result, err := handlePrometheusTargetsQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "Prometheus API error (404)") + }) +} + +func TestHandlePromql(t *testing.T) { + t.Run("missing query description", func(t *testing.T) { + ctx := context.Background() + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{} + + result, err := handlePromql(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getResultText(result), "query_description is required") + }) + + t.Run("with query description", func(t *testing.T) { + ctx := context.Background() + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query_description": "CPU usage percentage", + } + + result, err := handlePromql(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + // This will likely fail due to missing OpenAI API key, but that's expected + // We're testing that the function handles the error gracefully + if result.IsError { + content := getResultText(result) + // Should contain an error about LLM client or API + assert.True(t, + strings.Contains(content, "failed to create LLM client") || + strings.Contains(content, "failed to generate content") || + strings.Contains(content, "API"), + ) + } + }) +} + +// Test context cancellation scenarios +func TestPrometheusToolsContextCancellation(t *testing.T) { + t.Run("query tool with cancelled context", func(t *testing.T) { + // Create a mock client that would block indefinitely + client := newTestClient(createMockResponse(200, `{"status": "success"}`), nil) + + // Create a cancelled context + cancelCtx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + ctx := contextWithMockClient(client) + _ = cancelCtx + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up", + } + + result, err := handlePrometheusQueryTool(ctx, request) + + // Should handle cancellation gracefully + assert.NoError(t, err) + assert.NotNil(t, result) + }) +} + +// Test edge cases and error scenarios +func TestPrometheusToolsEdgeCases(t *testing.T) { + t.Run("very large response", func(t *testing.T) { + // Create a large response (simulating large metrics data) + largeResponse := `{"status": "success", "data": {"result": [` + for i := 0; i < 1000; i++ { + if i > 0 { + largeResponse += "," + } + largeResponse += `{"metric": {"instance": "host` + string(rune(i)) + `"}, "value": [1609459200, "1"]}` + } + largeResponse += `]}}` + + client := newTestClient(createMockResponse(200, largeResponse), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up", + } + + result, err := handlePrometheusQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + content := getResultText(result) + assert.Contains(t, content, "success") + }) + + t.Run("special characters in query", func(t *testing.T) { + mockResponse := `{"status": "success", "data": {"result": []}}` + client := newTestClient(createMockResponse(200, mockResponse), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": `up{instance=~".*:9090"}`, + } + + result, err := handlePrometheusQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + }) + + t.Run("empty response body", func(t *testing.T) { + client := newTestClient(createMockResponse(200, ""), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up", + } + + result, err := handlePrometheusQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + }) +} + +// Test URL parameter encoding +func TestPrometheusURLEncoding(t *testing.T) { + t.Run("query with special characters", func(t *testing.T) { + mockResponse := `{"status": "success", "data": {"result": []}}` + client := newTestClient(createMockResponse(200, mockResponse), nil) + ctx := contextWithMockClient(client) + + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]interface{}{ + "query": "up{job=\"test service\"}", + } + + result, err := handlePrometheusQueryTool(ctx, request) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + // Test passes if no error occurs with special characters + content := getResultText(result) + assert.Contains(t, content, "success") + }) +} diff --git a/pkg/utils/common.go b/pkg/utils/common.go index d8be795d..23070889 100644 --- a/pkg/utils/common.go +++ b/pkg/utils/common.go @@ -3,314 +3,49 @@ package utils import ( "context" "fmt" - "os/exec" - "runtime" "strings" + "sync" "time" - "github.com/kagent-dev/tools/pkg/logger" + "github.com/kagent-dev/tools/internal/commands" + "github.com/kagent-dev/tools/internal/logger" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/metric" ) -// ShellExecutor defines the interface for executing shell commands -type ShellExecutor interface { - Exec(ctx context.Context, command string, args ...string) (output []byte, err error) +// KubeConfigManager manages kubeconfig path with thread safety +type KubeConfigManager struct { + mu sync.RWMutex + kubeconfigPath string } -// DefaultShellExecutor implements ShellExecutor using os/exec -type DefaultShellExecutor struct{} +// globalKubeConfigManager is the singleton instance +var globalKubeConfigManager = &KubeConfigManager{} -// Exec executes a command using os/exec.CommandContext -func (e *DefaultShellExecutor) Exec(ctx context.Context, command string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, command, args...) - return cmd.CombinedOutput() -} - -// MockShellExecutor implements ShellExecutor for testing -type MockShellExecutor struct { - // Commands maps command+args to expected output and error - Commands map[string]MockCommandResult - // CallLog keeps track of all executed commands for verification - CallLog []MockCommandCall - // PartialMatchers allows partial matching for dynamic arguments - PartialMatchers []PartialMatcher -} - -// PartialMatcher represents a partial command matcher for dynamic arguments -type PartialMatcher struct { - Command string - Args []string // Use "*" for wildcard matching - Result MockCommandResult -} - -// MockCommandResult represents the expected result of a mocked command -type MockCommandResult struct { - Output []byte - Error error -} - -// MockCommandCall represents a logged command execution -type MockCommandCall struct { - Command string - Args []string -} - -// Exec executes a mocked command -func (m *MockShellExecutor) Exec(ctx context.Context, command string, args ...string) ([]byte, error) { - // Log the call - m.CallLog = append(m.CallLog, MockCommandCall{ - Command: command, - Args: args, - }) - - // Try exact match first - key := m.commandKey(command, args...) - if result, exists := m.Commands[key]; exists { - return result.Output, result.Error - } - - // Try partial matchers - for _, matcher := range m.PartialMatchers { - if m.matchesPartial(command, args, matcher) { - return matcher.Result.Output, matcher.Result.Error - } - } - - // Default behavior for unmocked commands - return []byte(""), fmt.Errorf("unmocked command: %s %v", command, args) -} - -// matchesPartial checks if a command matches a partial matcher -func (m *MockShellExecutor) matchesPartial(command string, args []string, matcher PartialMatcher) bool { - if command != matcher.Command { - return false - } - - if len(args) != len(matcher.Args) { - return false - } - - for i, expectedArg := range matcher.Args { - if expectedArg == "*" { - continue // Wildcard match - } - if args[i] != expectedArg { - return false - } - } - - return true -} - -// AddCommand adds a command mock -func (m *MockShellExecutor) AddCommand(command string, args []string, output []byte, err error) { - if m.Commands == nil { - m.Commands = make(map[string]MockCommandResult) - } - key := m.commandKey(command, args...) - m.Commands[key] = MockCommandResult{ - Output: output, - Error: err, - } -} - -// AddCommandString is a convenience method for adding string output -func (m *MockShellExecutor) AddCommandString(command string, args []string, output string, err error) { - m.AddCommand(command, args, []byte(output), err) -} - -// AddPartialMatcher adds a partial matcher for dynamic arguments -func (m *MockShellExecutor) AddPartialMatcher(command string, args []string, output []byte, err error) { - if m.PartialMatchers == nil { - m.PartialMatchers = []PartialMatcher{} - } - m.PartialMatchers = append(m.PartialMatchers, PartialMatcher{ - Command: command, - Args: args, - Result: MockCommandResult{ - Output: output, - Error: err, - }, - }) -} - -// AddPartialMatcherString is a convenience method for adding string output with partial matching -func (m *MockShellExecutor) AddPartialMatcherString(command string, args []string, output string, err error) { - m.AddPartialMatcher(command, args, []byte(output), err) -} - -// GetCallLog returns the log of all command calls -func (m *MockShellExecutor) GetCallLog() []MockCommandCall { - return m.CallLog -} - -// Reset clears the mock state -func (m *MockShellExecutor) Reset() { - m.Commands = make(map[string]MockCommandResult) - m.CallLog = []MockCommandCall{} - m.PartialMatchers = []PartialMatcher{} -} - -// commandKey creates a unique key for command+args combination -func (m *MockShellExecutor) commandKey(command string, args ...string) string { - return fmt.Sprintf("%s %s", command, strings.Join(args, " ")) -} +// SetKubeconfig sets the global kubeconfig path in a thread-safe manner +func SetKubeconfig(path string) { + globalKubeConfigManager.mu.Lock() + defer globalKubeConfigManager.mu.Unlock() -// Context key for shell executor injection -type contextKey string - -const shellExecutorKey contextKey = "shellExecutor" - -// WithShellExecutor returns a context with the given shell executor -func WithShellExecutor(ctx context.Context, executor ShellExecutor) context.Context { - return context.WithValue(ctx, shellExecutorKey, executor) + globalKubeConfigManager.kubeconfigPath = path + logger.Get().Info("Setting shared kubeconfig", "path", path) } -// GetShellExecutor retrieves the shell executor from context, or returns default -func GetShellExecutor(ctx context.Context) ShellExecutor { - if executor, ok := ctx.Value(shellExecutorKey).(ShellExecutor); ok { - return executor - } - return &DefaultShellExecutor{} -} +// GetKubeconfig returns the global kubeconfig path in a thread-safe manner +func GetKubeconfig() string { + globalKubeConfigManager.mu.RLock() + defer globalKubeConfigManager.mu.RUnlock() -// NewMockShellExecutor creates a new mock shell executor for testing -func NewMockShellExecutor() *MockShellExecutor { - return &MockShellExecutor{ - Commands: make(map[string]MockCommandResult), - CallLog: []MockCommandCall{}, - PartialMatchers: []PartialMatcher{}, - } + return globalKubeConfigManager.kubeconfigPath } -var ( - tracer = otel.Tracer("kagent-tools") - meter = otel.Meter("kagent-tools") - - // Metrics - commandExecutionCounter metric.Int64Counter - commandExecutionDuration metric.Float64Histogram - commandExecutionErrors metric.Int64Counter -) - -func init() { - // Initialize metrics (these are safe to call even if OTEL is not configured) - var err error - - commandExecutionCounter, err = meter.Int64Counter( - "command_executions_total", - metric.WithDescription("Total number of command executions"), - ) - if err != nil { - logger.Get().Error(err, "Failed to create command execution counter") - } - - commandExecutionDuration, err = meter.Float64Histogram( - "command_execution_duration_seconds", - metric.WithDescription("Duration of command executions in seconds"), - metric.WithUnit("s"), - ) - if err != nil { - logger.Get().Error(err, "Failed to create command execution duration histogram") - } - - commandExecutionErrors, err = meter.Int64Counter( - "command_execution_errors_total", - metric.WithDescription("Total number of command execution errors"), - ) - if err != nil { - logger.Get().Error(err, "Failed to create command execution errors counter") +// AddKubeconfigArgs adds kubeconfig arguments to command args if configured +func AddKubeconfigArgs(args []string) []string { + kubeconfigPath := GetKubeconfig() + if kubeconfigPath != "" { + return append([]string{"--kubeconfig", kubeconfigPath}, args...) } -} - -// RunCommand executes a command and returns output or error with OTEL tracing -func RunCommand(command string, args []string) (string, error) { - return RunCommandWithContext(context.Background(), command, args) -} - -// RunCommandWithContext executes a command with context and returns output or error with OTEL tracing -func RunCommandWithContext(ctx context.Context, command string, args []string) (string, error) { - // Get caller information for tracing - _, file, line, _ := runtime.Caller(1) - caller := fmt.Sprintf("%s:%d", file, line) - - // Start OpenTelemetry span - spanName := fmt.Sprintf("exec.%s", command) - ctx, span := tracer.Start(ctx, spanName) - defer span.End() - - // Set span attributes - span.SetAttributes( - attribute.String("command", command), - attribute.StringSlice("args", args), - attribute.String("caller", caller), - ) - - // Record metrics - startTime := time.Now() - - // Use the shell executor from context (or default) - executor := GetShellExecutor(ctx) - output, err := executor.Exec(ctx, command, args...) - - duration := time.Since(startTime) - - // Set additional span attributes with results - span.SetAttributes( - attribute.Float64("duration_seconds", duration.Seconds()), - attribute.Int("output_size", len(output)), - ) - - // Record metrics - attributes := []attribute.KeyValue{ - attribute.String("command", command), - attribute.Bool("success", err == nil), - } - - if commandExecutionCounter != nil { - commandExecutionCounter.Add(ctx, 1, metric.WithAttributes(attributes...)) - } - - if commandExecutionDuration != nil { - commandExecutionDuration.Record(ctx, duration.Seconds(), metric.WithAttributes(attributes...)) - } - - if err != nil { - // Set span status and record error - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) - span.SetAttributes(attribute.String("error", err.Error())) - - if commandExecutionErrors != nil { - commandExecutionErrors.Add(ctx, 1, metric.WithAttributes(attributes...)) - } - - logger.Get().Error(err, "CommandExec failed", - "command", command, - "args", args, - "duration", duration, - "caller", caller, - ) - return "", fmt.Errorf("command %s failed: %v", command, err) - } - - // Set successful span status - span.SetStatus(codes.Ok, "CommandExec") - - logger.Get().Info("CommandExec", - "command", command, - "args", args, - "duration", duration, - "outputSize", len(output), - "caller", caller, - ) - - return strings.TrimSpace(string(output)), nil + return args } // shellTool provides shell command execution functionality @@ -328,27 +63,45 @@ func shellTool(ctx context.Context, params shellParams) (string, error) { cmd := parts[0] args := parts[1:] - return RunCommandWithContext(ctx, cmd, args) + return commands.NewCommandBuilder(cmd).WithArgs(args...).Execute(ctx) } -func RegisterCommonTools(s *server.MCPServer) { - s.AddTool(mcp.NewTool("shell", - mcp.WithDescription("Execute shell commands"), - mcp.WithString("command", mcp.Description("The shell command to execute"), mcp.Required()), - ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - command := mcp.ParseString(request, "command", "") - if command == "" { - return mcp.NewToolResultError("command parameter is required"), nil - } +// handleGetCurrentDateTimeTool provides datetime functionality for both MCP and testing +func handleGetCurrentDateTimeTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Returns the current date and time in ISO 8601 format (RFC3339) + // This matches the Python implementation: datetime.datetime.now().isoformat() + now := time.Now() + return mcp.NewToolResultText(now.Format(time.RFC3339)), nil +} + +func RegisterTools(s *server.MCPServer, readOnly bool) { + logger.Get().Info("RegisterTools initialized") + + // Register shell tool - disabled in read-only mode as it allows arbitrary command execution + if !readOnly { + s.AddTool(mcp.NewTool("shell", + mcp.WithDescription("Execute shell commands"), + mcp.WithString("command", mcp.Description("The shell command to execute"), mcp.Required()), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + command := mcp.ParseString(request, "command", "") + if command == "" { + return mcp.NewToolResultError("command parameter is required"), nil + } - params := shellParams{Command: command} - result, err := shellTool(ctx, params) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + params := shellParams{Command: command} + result, err := shellTool(ctx, params) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + return mcp.NewToolResultText(result), nil + }) + } - return mcp.NewToolResultText(result), nil - }) + // Register datetime tool + s.AddTool(mcp.NewTool("datetime_get_current_time", + mcp.WithDescription("Returns the current date and time in ISO 8601 format."), + ), handleGetCurrentDateTimeTool) // Note: LLM Tool implementation would go here if needed } diff --git a/pkg/utils/common_test.go b/pkg/utils/common_test.go deleted file mode 100644 index e21cf764..00000000 --- a/pkg/utils/common_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package utils - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDefaultShellExecutor(t *testing.T) { - executor := &DefaultShellExecutor{} - - // Test successful command - output, err := executor.Exec(context.Background(), "echo", "hello") - assert.NoError(t, err) - assert.Equal(t, "hello\n", string(output)) - - // Test command with error - output, err = executor.Exec(context.Background(), "nonexistent-command") - assert.Error(t, err) - assert.Empty(t, output) -} - -func TestMockShellExecutor(t *testing.T) { - mock := NewMockShellExecutor() - - t.Run("unmocked command returns error", func(t *testing.T) { - output, err := mock.Exec(context.Background(), "unmocked", "command") - assert.Error(t, err) - assert.Contains(t, err.Error(), "unmocked command") - assert.Empty(t, output) - }) - - t.Run("mocked command returns expected result", func(t *testing.T) { - expectedOutput := "mocked output" - mock.AddCommandString("kubectl", []string{"get", "pods"}, expectedOutput, nil) - - output, err := mock.Exec(context.Background(), "kubectl", "get", "pods") - assert.NoError(t, err) - assert.Equal(t, expectedOutput, string(output)) - }) - - t.Run("mocked command with error", func(t *testing.T) { - expectedError := errors.New("mocked error") - mock.AddCommandString("helm", []string{"install", "app"}, "", expectedError) - - output, err := mock.Exec(context.Background(), "helm", "install", "app") - assert.Error(t, err) - assert.Equal(t, expectedError, err) - assert.Empty(t, output) - }) - - t.Run("call log tracking", func(t *testing.T) { - mock.Reset() - - // Execute some commands - mock.AddCommandString("cmd1", []string{"arg1"}, "output1", nil) - mock.AddCommandString("cmd2", []string{"arg2", "arg3"}, "output2", nil) - - _, _ = mock.Exec(context.Background(), "cmd1", "arg1") - _, _ = mock.Exec(context.Background(), "cmd2", "arg2", "arg3") - _, _ = mock.Exec(context.Background(), "unmocked", "command") - - callLog := mock.GetCallLog() - require.Len(t, callLog, 3) - - assert.Equal(t, "cmd1", callLog[0].Command) - assert.Equal(t, []string{"arg1"}, callLog[0].Args) - - assert.Equal(t, "cmd2", callLog[1].Command) - assert.Equal(t, []string{"arg2", "arg3"}, callLog[1].Args) - - assert.Equal(t, "unmocked", callLog[2].Command) - assert.Equal(t, []string{"command"}, callLog[2].Args) - }) - - t.Run("reset functionality", func(t *testing.T) { - // Create a fresh mock for this test - freshMock := NewMockShellExecutor() - freshMock.AddCommandString("test", []string{}, "output", nil) - _, _ = freshMock.Exec(context.Background(), "test") - - assert.Len(t, freshMock.Commands, 1) - assert.Len(t, freshMock.CallLog, 1) - - freshMock.Reset() - - assert.Len(t, freshMock.Commands, 0) - assert.Len(t, freshMock.CallLog, 0) - }) -} - -func TestContextShellExecutor(t *testing.T) { - t.Run("default executor when no context value", func(t *testing.T) { - ctx := context.Background() - executor := GetShellExecutor(ctx) - - _, ok := executor.(*DefaultShellExecutor) - assert.True(t, ok, "should return DefaultShellExecutor when no context value") - }) - - t.Run("mock executor from context", func(t *testing.T) { - mock := NewMockShellExecutor() - ctx := WithShellExecutor(context.Background(), mock) - - executor := GetShellExecutor(ctx) - assert.Equal(t, mock, executor, "should return the mock executor from context") - }) - - t.Run("context propagation", func(t *testing.T) { - mock := NewMockShellExecutor() - mock.AddCommandString("test", []string{"arg"}, "test output", nil) - - ctx := WithShellExecutor(context.Background(), mock) - - // Test that RunCommandWithContext uses the mock - output, err := RunCommandWithContext(ctx, "test", []string{"arg"}) - assert.NoError(t, err) - assert.Equal(t, "test output", output) - - // Verify the command was logged - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "test", callLog[0].Command) - assert.Equal(t, []string{"arg"}, callLog[0].Args) - }) -} - -func TestRunCommandWithMocking(t *testing.T) { - t.Run("successful command execution with mock", func(t *testing.T) { - mock := NewMockShellExecutor() - mock.AddCommandString("kubectl", []string{"get", "pods", "-n", "default"}, "pod1\npod2", nil) - - ctx := WithShellExecutor(context.Background(), mock) - - output, err := RunCommandWithContext(ctx, "kubectl", []string{"get", "pods", "-n", "default"}) - assert.NoError(t, err) - assert.Equal(t, "pod1\npod2", output) - - // Verify command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "kubectl", callLog[0].Command) - assert.Equal(t, []string{"get", "pods", "-n", "default"}, callLog[0].Args) - }) - - t.Run("command failure with mock", func(t *testing.T) { - mock := NewMockShellExecutor() - expectedError := errors.New("command failed") - mock.AddCommandString("helm", []string{"install", "app"}, "", expectedError) - - ctx := WithShellExecutor(context.Background(), mock) - - output, err := RunCommandWithContext(ctx, "helm", []string{"install", "app"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "command helm failed") - assert.Empty(t, output) - }) - - t.Run("multiple commands with mock", func(t *testing.T) { - mock := NewMockShellExecutor() - mock.AddCommandString("kubectl", []string{"get", "pods"}, "pod-list", nil) - mock.AddCommandString("kubectl", []string{"get", "services"}, "service-list", nil) - mock.AddCommandString("helm", []string{"list"}, "helm-releases", nil) - - ctx := WithShellExecutor(context.Background(), mock) - - // Execute multiple commands - output1, err1 := RunCommandWithContext(ctx, "kubectl", []string{"get", "pods"}) - assert.NoError(t, err1) - assert.Equal(t, "pod-list", output1) - - output2, err2 := RunCommandWithContext(ctx, "kubectl", []string{"get", "services"}) - assert.NoError(t, err2) - assert.Equal(t, "service-list", output2) - - output3, err3 := RunCommandWithContext(ctx, "helm", []string{"list"}) - assert.NoError(t, err3) - assert.Equal(t, "helm-releases", output3) - - // Verify all commands were logged - callLog := mock.GetCallLog() - require.Len(t, callLog, 3) - - assert.Equal(t, "kubectl", callLog[0].Command) - assert.Equal(t, []string{"get", "pods"}, callLog[0].Args) - - assert.Equal(t, "kubectl", callLog[1].Command) - assert.Equal(t, []string{"get", "services"}, callLog[1].Args) - - assert.Equal(t, "helm", callLog[2].Command) - assert.Equal(t, []string{"list"}, callLog[2].Args) - }) -} - -func TestShellToolWithMocking(t *testing.T) { - t.Run("shell tool uses mock executor", func(t *testing.T) { - mock := NewMockShellExecutor() - mock.AddCommandString("echo", []string{"hello", "world"}, "hello world", nil) - - ctx := WithShellExecutor(context.Background(), mock) - - params := shellParams{Command: "echo hello world"} - output, err := shellTool(ctx, params) - assert.NoError(t, err) - assert.Equal(t, "hello world", output) - - // Verify command was called - callLog := mock.GetCallLog() - require.Len(t, callLog, 1) - assert.Equal(t, "echo", callLog[0].Command) - assert.Equal(t, []string{"hello", "world"}, callLog[0].Args) - }) - - t.Run("shell tool with empty command", func(t *testing.T) { - mock := NewMockShellExecutor() - ctx := WithShellExecutor(context.Background(), mock) - - params := shellParams{Command: ""} - output, err := shellTool(ctx, params) - assert.Error(t, err) - assert.Contains(t, err.Error(), "empty command") - assert.Empty(t, output) - - // No commands should be logged - callLog := mock.GetCallLog() - assert.Len(t, callLog, 0) - }) -} - -func TestMockShellExecutorCommandKey(t *testing.T) { - mock := NewMockShellExecutor() - - // Test that different argument combinations create different keys - mock.AddCommandString("kubectl", []string{"get", "pods"}, "pods", nil) - mock.AddCommandString("kubectl", []string{"get", "services"}, "services", nil) - mock.AddCommandString("kubectl", []string{}, "kubectl-help", nil) - - // Test first command - output, err := mock.Exec(context.Background(), "kubectl", "get", "pods") - assert.NoError(t, err) - assert.Equal(t, "pods", string(output)) - - // Test second command - output, err = mock.Exec(context.Background(), "kubectl", "get", "services") - assert.NoError(t, err) - assert.Equal(t, "services", string(output)) - - // Test third command (no args) - output, err = mock.Exec(context.Background(), "kubectl") - assert.NoError(t, err) - assert.Equal(t, "kubectl-help", string(output)) -} - -// Benchmark tests to ensure mocking doesn't add significant overhead -func BenchmarkDefaultShellExecutor(b *testing.B) { - executor := &DefaultShellExecutor{} - ctx := context.Background() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = executor.Exec(ctx, "echo", "test") - } -} - -func BenchmarkMockShellExecutor(b *testing.B) { - mock := NewMockShellExecutor() - mock.AddCommandString("echo", []string{"test"}, "test", nil) - ctx := context.Background() - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = mock.Exec(ctx, "echo", "test") - } -} - -func BenchmarkRunCommandWithContext(b *testing.B) { - mock := NewMockShellExecutor() - mock.AddCommandString("echo", []string{"test"}, "test", nil) - ctx := WithShellExecutor(context.Background(), mock) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = RunCommandWithContext(ctx, "echo", []string{"test"}) - } -} diff --git a/pkg/utils/datetime.go b/pkg/utils/datetime.go index f0c6d246..3bca950b 100644 --- a/pkg/utils/datetime.go +++ b/pkg/utils/datetime.go @@ -1,26 +1,5 @@ package utils -import ( - "context" - "time" - - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" -) - -// DateTime tools using direct Go time package -// This implementation matches the Python version exactly - -func handleGetCurrentDateTimeTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Returns the current date and time in ISO 8601 format (RFC3339) - // This matches the Python implementation: datetime.datetime.now().isoformat() - now := time.Now() - return mcp.NewToolResultText(now.Format(time.RFC3339)), nil -} - -func RegisterDateTimeTools(s *server.MCPServer) { - // Register the GetCurrentDateTime tool to match Python implementation exactly - s.AddTool(mcp.NewTool("datetime_get_current_time", - mcp.WithDescription("Returns the current date and time in ISO 8601 format."), - ), handleGetCurrentDateTimeTool) -} +// DateTime tools implementation moved to RegisterTools function in common.go +// This file remains for backwards compatibility but the tools are now registered +// through the unified RegisterTools function. diff --git a/pkg/utils/datetime_test.go b/pkg/utils/datetime_test.go index 6b542599..8f1cd641 100644 --- a/pkg/utils/datetime_test.go +++ b/pkg/utils/datetime_test.go @@ -24,7 +24,7 @@ func TestHandleGetCurrentDateTimeTool(t *testing.T) { t.Fatal("Expected non-nil result") } - if result.Content == nil || len(result.Content) == 0 { + if len(result.Content) == 0 { t.Fatal("Expected content in result") } diff --git a/reports/cve-report.tmpl b/reports/cve-report.tmpl new file mode 100644 index 00000000..4518291b --- /dev/null +++ b/reports/cve-report.tmpl @@ -0,0 +1,4 @@ +"Package","Version Installed","Vulnerability ID","Severity","Location","Version Fixed" +{{- range .Matches}} +"{{.Artifact.Name}}","{{.Artifact.Version}}","{{.Vulnerability.ID}}","{{.Vulnerability.Severity}}","{{ (index .Artifact.Locations 0).RealPath }}","{{.Vulnerability.Fix.Versions | join ".."}}" +{{- end}} \ No newline at end of file diff --git a/scripts/agentgateway-config-tools.yaml b/scripts/agentgateway-config-tools.yaml new file mode 100644 index 00000000..ee53905a --- /dev/null +++ b/scripts/agentgateway-config-tools.yaml @@ -0,0 +1,22 @@ +binds: +- port: 30805 + listeners: + - routes: + - backends: + - mcp: + targets: + - name: kagent-tools + stdio: + cmd: kagent-tools + args: + - --stdio + - --kubeconfig + - ~/.kube/config + policies: + cors: + allowOrigins: + - '*' + allowHeaders: + - mcp-protocol-version + - content-type + - cache-control diff --git a/scripts/cilium/install-cilium.sh b/scripts/cilium/install-cilium.sh new file mode 100755 index 00000000..922a67ff --- /dev/null +++ b/scripts/cilium/install-cilium.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Install Cilium into a Kind cluster via the kagent-tools pod. +# +# Prerequisites: +# - Kind cluster running with kagent-tools deployed +# +# Usage: +# ./scripts/cilium/install-cilium.sh [NAMESPACE] [RELEASE_NAME] [KUBE_CONTEXT] +# +# Defaults: +# NAMESPACE = kagent +# RELEASE_NAME = kagent-tools +# KUBE_CONTEXT = kind-kagent + +set -euo pipefail + +NAMESPACE="${1:-kagent}" +RELEASE_NAME="${2:-kagent-tools}" +KUBE_CONTEXT="${3:-kind-kagent}" + +echo "Installing Cilium via kagent-tools pod in namespace=$NAMESPACE context=$KUBE_CONTEXT" + +# Find the kagent-tools pod +POD=$(kubectl --context "$KUBE_CONTEXT" get pods -n "$NAMESPACE" \ + -l "app.kubernetes.io/instance=$RELEASE_NAME" \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + +if [ -z "$POD" ]; then + echo "ERROR: No kagent-tools pod found in namespace $NAMESPACE" + exit 1 +fi + +echo "Using pod: $POD" + +# Install Cilium +kubectl --context "$KUBE_CONTEXT" exec -n "$NAMESPACE" "$POD" -- \ + cilium install \ + --set routingMode=native \ + --set ipv4NativeRoutingCIDR=10.244.0.0/16 \ + --set bpf.masquerade=false + +echo "" +echo "Waiting for Cilium pods to be ready..." +kubectl --context "$KUBE_CONTEXT" wait \ + --for=condition=ready pod \ + -l k8s-app=cilium \ + -n kube-system \ + --timeout=120s + +echo "" +echo "Cilium status:" +kubectl --context "$KUBE_CONTEXT" exec -n "$NAMESPACE" "$POD" -- cilium status diff --git a/scripts/cilium/test-mcp-tools.sh b/scripts/cilium/test-mcp-tools.sh new file mode 100755 index 00000000..3ae07d9a --- /dev/null +++ b/scripts/cilium/test-mcp-tools.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Test Cilium MCP tools against a running kagent-tools instance. +# +# Prerequisites: +# - Kind cluster "kagent" running with Cilium installed +# - kagent-tools deployed and accessible on NodePort 30884 +# +# Usage: +# ./scripts/cilium/test-mcp-tools.sh [MCP_URL] [NODE_NAME] +# +# Defaults: +# MCP_URL = http://127.0.0.1:30884/mcp +# NODE_NAME = kagent-control-plane + +set -euo pipefail + +MCP_URL="${1:-http://127.0.0.1:30884/mcp}" +NODE_NAME="${2:-kagent-control-plane}" + +PASS=0 +FAIL=0 +SKIP=0 + +# Initialize MCP session +SESSION_ID=$(curl -sf -D - -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"cilium-test","version":"1.0"}}}' \ + 2>&1 | grep -i 'mcp-session-id' | tr -d '\r' | awk '{print $2}') + +if [ -z "$SESSION_ID" ]; then + echo "ERROR: Failed to initialize MCP session at $MCP_URL" + exit 1 +fi +echo "MCP session: $SESSION_ID" +echo "" + +ID=2 + +call_tool() { + local name=$1 + local args=$2 + + RESULT=$(curl -sf -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":$ID,\"method\":\"tools/call\",\"params\":{\"name\":\"$name\",\"arguments\":$args}}" 2>&1 || true) + ID=$((ID + 1)) + + if [ -z "$RESULT" ]; then + echo "SKIP: $name (no response)" + SKIP=$((SKIP + 1)) + elif echo "$RESULT" | grep -q '"isError":true'; then + echo "FAIL: $name" + FAIL=$((FAIL + 1)) + else + echo "OK: $name" + PASS=$((PASS + 1)) + fi +} + +NODE_ARGS="{\"node_name\":\"$NODE_NAME\"}" + +echo "=== Cilium CLI tools ===" +call_tool "cilium_status_and_version" "{}" + +echo "" +echo "=== Cilium-dbg read-only tools ===" +call_tool "cilium_get_endpoints_list" "$NODE_ARGS" +call_tool "cilium_get_daemon_status" "$NODE_ARGS" +call_tool "cilium_list_identities" "$NODE_ARGS" +call_tool "cilium_display_encryption_state" "$NODE_ARGS" +call_tool "cilium_list_services" "$NODE_ARGS" +call_tool "cilium_list_metrics" "$NODE_ARGS" +call_tool "cilium_fqdn_cache" "$NODE_ARGS" +call_tool "cilium_show_dns_names" "$NODE_ARGS" +call_tool "cilium_show_configuration_options" "$NODE_ARGS" +call_tool "cilium_list_cluster_nodes" "$NODE_ARGS" +call_tool "cilium_list_node_ids" "$NODE_ARGS" +call_tool "cilium_list_bpf_maps" "$NODE_ARGS" +call_tool "cilium_list_ip_addresses" "$NODE_ARGS" +call_tool "cilium_display_selectors" "$NODE_ARGS" +call_tool "cilium_list_local_redirect_policies" "$NODE_ARGS" +call_tool "cilium_request_debugging_information" "$NODE_ARGS" +call_tool "cilium_show_load_information" "$NODE_ARGS" +call_tool "cilium_display_policy_node_information" "{\"node_name\":\"$NODE_NAME\",\"labels\":\"\"}" +call_tool "cilium_list_xdp_cidr_filters" "$NODE_ARGS" + +echo "" +echo "=== Results ===" +echo "PASS: $PASS FAIL: $FAIL SKIP: $SKIP TOTAL: $((PASS + FAIL + SKIP))" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..81c57880 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,252 @@ +#!/bin/bash + +# exit on error +set -e + +#do not output commands +set +x + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# Configuration +GITHUB_REPO="kagent-dev/tools" +BINARY_NAME="kagent-tools" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" + +# Helper functions +log_info() { + echo -e "${BLUE}ℹ️ ${NC}$1" >&2 +} + +log_success() { + echo -e "${GREEN}✅ ${NC}$1" >&2 +} + +log_step() { + echo -e "${CYAN}🔄 ${NC}$1" >&2 +} + +log_step_complete() { + # Move cursor up one line and clear it, then print the completed message + echo -e "\033[1A\033[2K${GREEN}✅ ${NC}$1" >&2 +} + +log_warn() { + echo -e "${YELLOW}⚠️ ${NC}$1" >&2 +} + +log_error() { + echo -e "${RED}❌ ${NC}$1" >&2 +} + +log_header() { + echo -e "\n${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" >&2 + echo -e "${BOLD}${CYAN} 🚀 kagent-tools Installer${NC}" >&2 + echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n" >&2 +} + +# Check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Detect OS and architecture +detect_platform() { + local os + local arch + + # Detect OS + case "$(uname -s)" in + Darwin) + os="darwin" + ;; + Linux) + os="linux" + ;; + CYGWIN*|MINGW32*|MSYS*|MINGW*) + os="windows" + ;; + *) + log_error "Unsupported operating system: $(uname -s)" + exit 1 + ;; + esac + + # Detect architecture + case "$(uname -m)" in + x86_64|amd64) + arch="amd64" + ;; + arm64|aarch64) + arch="arm64" + ;; + armv7l) + arch="arm" + ;; + *) + log_error "Unsupported architecture: $(uname -m)" + exit 1 + ;; + esac + + echo "${os}-${arch}" +} + +# Get latest release version from GitHub +get_latest_version() { + log_step "Fetching latest version from GitHub..." + + if command_exists curl; then + local response=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest") + elif command_exists wget; then + local response=$(wget -qO- "https://api.github.com/repos/${GITHUB_REPO}/releases/latest") + else + log_error "Neither curl nor wget is available. Please install one of them." + exit 1 + fi + + # Extract version using basic tools (avoiding jq dependency) + local version=$(echo "$response" | grep '"tag_name"' | sed -E 's/.*"tag_name": "([^"]+)".*/\1/') + + if [ -z "$version" ]; then + echo "" >&2 # Add newline before error + log_error "Failed to get latest version from GitHub API" + exit 1 + fi + + echo "$version" +} + +# Download binary +download_binary() { + local version="$1" + local platform="$2" + local download_url="https://github.com/${GITHUB_REPO}/releases/download/${version}/${BINARY_NAME}-${platform}" + local temp_file="/tmp/${BINARY_NAME}" + rm -rf "/tmp/${BINARY_NAME}" + + log_step "Downloading ${BINARY_NAME} ${BOLD}${version}${NC} for ${BOLD}${platform}${NC} from GitHub..." + + if command_exists curl; then + curl -sL -o "$temp_file" "$download_url" + elif command_exists wget; then + wget -q -O "$temp_file" "$download_url" + else + log_error "Neither curl nor wget is available. Please install one of them." + exit 1 + fi + + if [ ! -f "$temp_file" ]; then + echo "" >&2 # Add newline before error + log_error "Failed to download binary from $download_url" + exit 1 + fi + + echo "$temp_file" +} + +# Install binary +install_binary() { + local temp_file="$1" + local install_path="${INSTALL_DIR}/${BINARY_NAME}" + + log_step "Installing ${BINARY_NAME} to ${BOLD}${install_path}${NC}..." + + # Create install directory if it doesn't exist + if [ ! -d "$INSTALL_DIR" ]; then + echo -e "\n${BLUE}ℹ️ ${NC}📁 Creating install directory: ${INSTALL_DIR}" >&2 + mkdir -p "$INSTALL_DIR" + fi + + mv "$temp_file" "$install_path" + chmod +x "$install_path" + + # Cleanup + rm -f "$temp_file" +} + +# Verify installation +verify_installation() { + log_step "Verifying installation..." + + if command_exists "$BINARY_NAME"; then + local version=$("$BINARY_NAME" --version 2>/dev/null || echo "unknown") + log_step_complete "Installation verified! ${BINARY_NAME} is available in PATH" + echo -e "${BLUE}ℹ️ ${NC}🏷️ Version: ${BOLD}${version}${NC}" >&2 + else + echo "" >&2 # Add newline before warning + log_warn "${BINARY_NAME} is installed but not in PATH" + log_info "💡 Add this line to your shell profile (~/.bashrc, ~/.zshrc, etc.):" + echo -e "${CYAN} export PATH=\"${INSTALL_DIR}:\$PATH\"${NC}" >&2 + fi +} + +# Main installation function +main() { + log_header + + # Check prerequisites + log_step "Checking prerequisites..." + if ! command_exists curl && ! command_exists wget; then + echo "" >&2 # Add newline before error + log_error "This script requires either curl or wget to be installed." + exit 1 + fi + log_step_complete "Prerequisites check passed" + + # Detect platform + log_step "Detecting platform..." + local platform=$(detect_platform) + log_step_complete "Platform detected: ${BOLD}${platform}${NC}" + + # Get latest version + local version=$(get_latest_version) + log_step_complete "Found latest version: ${BOLD}${version}${NC}" + + # Download binary + local temp_file=$(download_binary "$version" "$platform") + log_step_complete "Binary downloaded successfully" + + # Install binary + install_binary "$temp_file" + log_step_complete "Binary installed successfully" + + # Verify installation + verify_installation + + echo -e "\n${BOLD}${GREEN}🎉 Installation completed successfully!${NC}" >&2 + echo -e "${GREEN} You can now use '${BOLD}kagent-tools${NC}${GREEN}' command.${NC}\n" >&2 +} + +# Handle command line arguments +case "${1:-}" in + -h|--help) + echo -e "${BOLD}${CYAN}🚀 kagent-tools Installer${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "\n${BOLD}USAGE:${NC}" + echo -e " $0 [OPTIONS]" + echo -e "\n${BOLD}DESCRIPTION:${NC}" + echo -e " Install kagent-tools from GitHub releases" + echo -e "\n${BOLD}OPTIONS:${NC}" + echo -e " -h, --help Show this help message" + echo -e "\n${BOLD}ENVIRONMENT VARIABLES:${NC}" + echo -e " INSTALL_DIR Installation directory (default: \$HOME/.local/bin)" + echo -e "\n${BOLD}EXAMPLES:${NC}" + echo -e " ${GREEN}$0${NC} # Install to \$HOME/.local/bin" + echo -e " ${GREEN}INSTALL_DIR=~/bin $0${NC} # Install to ~/bin" + echo "" + exit 0 + ;; + *) + main "$@" + ;; +esac + + diff --git a/scripts/kind/crd-argo.yaml b/scripts/kind/crd-argo.yaml new file mode 100644 index 00000000..23dff637 --- /dev/null +++ b/scripts/kind/crd-argo.yaml @@ -0,0 +1,16470 @@ +# This is an auto-generated file. DO NOT EDIT +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: analysisruns.argoproj.io +spec: + group: argoproj.io + names: + kind: AnalysisRun + listKind: AnalysisRunList + plural: analysisruns + shortNames: + - ar + singular: analysisrun + preserveUnknownFields: false + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: AnalysisRun status + jsonPath: .status.phase + name: Status + type: string + - description: Time since resource was created + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + metrics: + items: + properties: + consecutiveErrorLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + consecutiveSuccessLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + count: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + failureCondition: + type: string + failureLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + inconclusiveLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + initialDelay: + type: string + interval: + type: string + name: + type: string + provider: + properties: + cloudWatch: + properties: + interval: + type: string + metricDataQueries: + items: + properties: + expression: + type: string + id: + type: string + label: + type: string + metricStat: + properties: + metric: + properties: + dimensions: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + metricName: + type: string + namespace: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + stat: + type: string + unit: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + returnData: + type: boolean + type: object + type: array + required: + - metricDataQueries + type: object + datadog: + properties: + aggregator: + enum: + - avg + - min + - max + - sum + - last + - percentile + - mean + - l2norm + - area + type: string + apiVersion: + default: v1 + enum: + - v1 + - v2 + type: string + formula: + type: string + interval: + default: 5m + type: string + queries: + additionalProperties: + type: string + type: object + query: + type: string + secretRef: + properties: + name: + type: string + namespaced: + type: boolean + type: object + type: object + graphite: + properties: + address: + type: string + query: + type: string + type: object + influxdb: + properties: + profile: + type: string + query: + type: string + type: object + job: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + backoffLimit: + format: int32 + type: integer + backoffLimitPerIndex: + format: int32 + type: integer + completionMode: + type: string + completions: + format: int32 + type: integer + manualSelector: + type: boolean + maxFailedIndexes: + format: int32 + type: integer + parallelism: + format: int32 + type: integer + podFailurePolicy: + properties: + rules: + items: + properties: + action: + type: string + onExitCodes: + properties: + containerName: + type: string + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + - values + type: object + onPodConditions: + items: + properties: + status: + type: string + type: + type: string + required: + - status + - type + type: object + type: array + x-kubernetes-list-type: atomic + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + required: + - rules + type: object + podReplacementPolicy: + type: string + selector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + suspend: + type: boolean + template: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + affinity: + properties: + nodeAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + preference: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + x-kubernetes-map-type: atomic + weight: + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + properties: + nodeSelectorTerms: + items: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + x-kubernetes-map-type: atomic + type: array + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + podAntiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + type: object + automountServiceAccountToken: + type: boolean + containers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + dnsConfig: + properties: + nameservers: + items: + type: string + type: array + options: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + searches: + items: + type: string + type: array + type: object + dnsPolicy: + type: string + enableServiceLinks: + type: boolean + ephemeralContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + targetContainerName: + type: string + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + hostAliases: + items: + properties: + hostnames: + items: + type: string + type: array + ip: + type: string + type: object + type: array + hostIPC: + type: boolean + hostNetwork: + type: boolean + hostPID: + type: boolean + hostUsers: + type: boolean + hostname: + type: string + imagePullSecrets: + items: + properties: + name: + type: string + type: object + x-kubernetes-map-type: atomic + type: array + initContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + nodeName: + type: string + nodeSelector: + additionalProperties: + type: string + type: object + x-kubernetes-map-type: atomic + os: + properties: + name: + type: string + required: + - name + type: object + overhead: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + preemptionPolicy: + type: string + priority: + format: int32 + type: integer + priorityClassName: + type: string + readinessGates: + items: + properties: + conditionType: + type: string + required: + - conditionType + type: object + type: array + resourceClaims: + items: + properties: + name: + type: string + source: + properties: + resourceClaimName: + type: string + resourceClaimTemplateName: + type: string + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + restartPolicy: + type: string + runtimeClassName: + type: string + schedulerName: + type: string + schedulingGates: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + securityContext: + properties: + fsGroup: + format: int64 + type: integer + fsGroupChangePolicy: + type: string + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + supplementalGroups: + items: + format: int64 + type: integer + type: array + sysctls: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + serviceAccount: + type: string + serviceAccountName: + type: string + setHostnameAsFQDN: + type: boolean + shareProcessNamespace: + type: boolean + subdomain: + type: string + terminationGracePeriodSeconds: + format: int64 + type: integer + tolerations: + items: + properties: + effect: + type: string + key: + type: string + operator: + type: string + tolerationSeconds: + format: int64 + type: integer + value: + type: string + type: object + type: array + topologySpreadConstraints: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + format: int32 + type: integer + minDomains: + format: int32 + type: integer + nodeAffinityPolicy: + type: string + nodeTaintsPolicy: + type: string + topologyKey: + type: string + whenUnsatisfiable: + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + x-kubernetes-list-map-keys: + - topologyKey + - whenUnsatisfiable + x-kubernetes-list-type: map + volumes: + x-kubernetes-preserve-unknown-fields: true + required: + - containers + type: object + type: object + ttlSecondsAfterFinished: + format: int32 + type: integer + required: + - template + type: object + required: + - spec + type: object + kayenta: + properties: + address: + type: string + application: + type: string + canaryConfigName: + type: string + configurationAccountName: + type: string + metricsAccountName: + type: string + scopes: + items: + properties: + controlScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - end + - region + - scope + - start + - step + type: object + experimentScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - end + - region + - scope + - start + - step + type: object + name: + type: string + required: + - controlScope + - experimentScope + - name + type: object + type: array + storageAccountName: + type: string + threshold: + properties: + marginal: + format: int64 + type: integer + pass: + format: int64 + type: integer + required: + - marginal + - pass + type: object + required: + - address + - application + - canaryConfigName + - configurationAccountName + - metricsAccountName + - scopes + - storageAccountName + - threshold + type: object + newRelic: + properties: + profile: + type: string + query: + type: string + timeout: + format: int64 + type: integer + required: + - query + type: object + plugin: + type: object + x-kubernetes-preserve-unknown-fields: true + prometheus: + properties: + address: + type: string + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + query: + type: string + rangeQuery: + properties: + end: + type: string + start: + type: string + step: + type: string + type: object + timeout: + format: int64 + type: integer + type: object + skywalking: + properties: + address: + type: string + interval: + type: string + query: + type: string + type: object + wavefront: + properties: + address: + type: string + query: + type: string + type: object + web: + properties: + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + body: + type: string + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + jsonBody: + type: object + x-kubernetes-preserve-unknown-fields: true + jsonPath: + type: string + method: + type: string + timeoutSeconds: + format: int64 + type: integer + url: + type: string + required: + - url + type: object + type: object + successCondition: + type: string + required: + - name + - provider + type: object + type: array + terminate: + type: boolean + ttlStrategy: + properties: + secondsAfterCompletion: + format: int32 + type: integer + secondsAfterFailure: + format: int32 + type: integer + secondsAfterSuccess: + format: int32 + type: integer + type: object + required: + - metrics + type: object + status: + properties: + completedAt: + format: date-time + type: string + dryRunSummary: + properties: + count: + format: int32 + type: integer + error: + format: int32 + type: integer + failed: + format: int32 + type: integer + inconclusive: + format: int32 + type: integer + successful: + format: int32 + type: integer + type: object + message: + type: string + metricResults: + items: + properties: + consecutiveError: + format: int32 + type: integer + consecutiveSuccess: + format: int32 + type: integer + count: + format: int32 + type: integer + dryRun: + type: boolean + error: + format: int32 + type: integer + failed: + format: int32 + type: integer + inconclusive: + format: int32 + type: integer + measurements: + items: + properties: + finishedAt: + format: date-time + type: string + message: + type: string + metadata: + additionalProperties: + type: string + type: object + phase: + type: string + resumeAt: + format: date-time + type: string + startedAt: + format: date-time + type: string + value: + type: string + required: + - phase + type: object + type: array + message: + type: string + metadata: + additionalProperties: + type: string + type: object + name: + type: string + phase: + type: string + successful: + format: int32 + type: integer + required: + - name + - phase + type: object + type: array + phase: + type: string + runSummary: + properties: + count: + format: int32 + type: integer + error: + format: int32 + type: integer + failed: + format: int32 + type: integer + inconclusive: + format: int32 + type: integer + successful: + format: int32 + type: integer + type: object + startedAt: + format: date-time + type: string + required: + - phase + type: object + required: + - spec + type: object + served: true + storage: true + subresources: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: analysistemplates.argoproj.io +spec: + group: argoproj.io + names: + kind: AnalysisTemplate + listKind: AnalysisTemplateList + plural: analysistemplates + shortNames: + - at + singular: analysistemplate + preserveUnknownFields: false + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Time since resource was created + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + metrics: + items: + properties: + consecutiveErrorLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + consecutiveSuccessLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + count: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + failureCondition: + type: string + failureLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + inconclusiveLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + initialDelay: + type: string + interval: + type: string + name: + type: string + provider: + properties: + cloudWatch: + properties: + interval: + type: string + metricDataQueries: + items: + properties: + expression: + type: string + id: + type: string + label: + type: string + metricStat: + properties: + metric: + properties: + dimensions: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + metricName: + type: string + namespace: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + stat: + type: string + unit: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + returnData: + type: boolean + type: object + type: array + required: + - metricDataQueries + type: object + datadog: + properties: + aggregator: + enum: + - avg + - min + - max + - sum + - last + - percentile + - mean + - l2norm + - area + type: string + apiVersion: + default: v1 + enum: + - v1 + - v2 + type: string + formula: + type: string + interval: + default: 5m + type: string + queries: + additionalProperties: + type: string + type: object + query: + type: string + secretRef: + properties: + name: + type: string + namespaced: + type: boolean + type: object + type: object + graphite: + properties: + address: + type: string + query: + type: string + type: object + influxdb: + properties: + profile: + type: string + query: + type: string + type: object + job: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + backoffLimit: + format: int32 + type: integer + backoffLimitPerIndex: + format: int32 + type: integer + completionMode: + type: string + completions: + format: int32 + type: integer + manualSelector: + type: boolean + maxFailedIndexes: + format: int32 + type: integer + parallelism: + format: int32 + type: integer + podFailurePolicy: + properties: + rules: + items: + properties: + action: + type: string + onExitCodes: + properties: + containerName: + type: string + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + - values + type: object + onPodConditions: + items: + properties: + status: + type: string + type: + type: string + required: + - status + - type + type: object + type: array + x-kubernetes-list-type: atomic + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + required: + - rules + type: object + podReplacementPolicy: + type: string + selector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + suspend: + type: boolean + template: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + affinity: + properties: + nodeAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + preference: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + x-kubernetes-map-type: atomic + weight: + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + properties: + nodeSelectorTerms: + items: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + x-kubernetes-map-type: atomic + type: array + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + podAntiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + type: object + automountServiceAccountToken: + type: boolean + containers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + dnsConfig: + properties: + nameservers: + items: + type: string + type: array + options: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + searches: + items: + type: string + type: array + type: object + dnsPolicy: + type: string + enableServiceLinks: + type: boolean + ephemeralContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + targetContainerName: + type: string + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + hostAliases: + items: + properties: + hostnames: + items: + type: string + type: array + ip: + type: string + type: object + type: array + hostIPC: + type: boolean + hostNetwork: + type: boolean + hostPID: + type: boolean + hostUsers: + type: boolean + hostname: + type: string + imagePullSecrets: + items: + properties: + name: + type: string + type: object + x-kubernetes-map-type: atomic + type: array + initContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + nodeName: + type: string + nodeSelector: + additionalProperties: + type: string + type: object + x-kubernetes-map-type: atomic + os: + properties: + name: + type: string + required: + - name + type: object + overhead: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + preemptionPolicy: + type: string + priority: + format: int32 + type: integer + priorityClassName: + type: string + readinessGates: + items: + properties: + conditionType: + type: string + required: + - conditionType + type: object + type: array + resourceClaims: + items: + properties: + name: + type: string + source: + properties: + resourceClaimName: + type: string + resourceClaimTemplateName: + type: string + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + restartPolicy: + type: string + runtimeClassName: + type: string + schedulerName: + type: string + schedulingGates: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + securityContext: + properties: + fsGroup: + format: int64 + type: integer + fsGroupChangePolicy: + type: string + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + supplementalGroups: + items: + format: int64 + type: integer + type: array + sysctls: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + serviceAccount: + type: string + serviceAccountName: + type: string + setHostnameAsFQDN: + type: boolean + shareProcessNamespace: + type: boolean + subdomain: + type: string + terminationGracePeriodSeconds: + format: int64 + type: integer + tolerations: + items: + properties: + effect: + type: string + key: + type: string + operator: + type: string + tolerationSeconds: + format: int64 + type: integer + value: + type: string + type: object + type: array + topologySpreadConstraints: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + format: int32 + type: integer + minDomains: + format: int32 + type: integer + nodeAffinityPolicy: + type: string + nodeTaintsPolicy: + type: string + topologyKey: + type: string + whenUnsatisfiable: + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + x-kubernetes-list-map-keys: + - topologyKey + - whenUnsatisfiable + x-kubernetes-list-type: map + volumes: + x-kubernetes-preserve-unknown-fields: true + required: + - containers + type: object + type: object + ttlSecondsAfterFinished: + format: int32 + type: integer + required: + - template + type: object + required: + - spec + type: object + kayenta: + properties: + address: + type: string + application: + type: string + canaryConfigName: + type: string + configurationAccountName: + type: string + metricsAccountName: + type: string + scopes: + items: + properties: + controlScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - end + - region + - scope + - start + - step + type: object + experimentScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - end + - region + - scope + - start + - step + type: object + name: + type: string + required: + - controlScope + - experimentScope + - name + type: object + type: array + storageAccountName: + type: string + threshold: + properties: + marginal: + format: int64 + type: integer + pass: + format: int64 + type: integer + required: + - marginal + - pass + type: object + required: + - address + - application + - canaryConfigName + - configurationAccountName + - metricsAccountName + - scopes + - storageAccountName + - threshold + type: object + newRelic: + properties: + profile: + type: string + query: + type: string + timeout: + format: int64 + type: integer + required: + - query + type: object + plugin: + type: object + x-kubernetes-preserve-unknown-fields: true + prometheus: + properties: + address: + type: string + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + query: + type: string + rangeQuery: + properties: + end: + type: string + start: + type: string + step: + type: string + type: object + timeout: + format: int64 + type: integer + type: object + skywalking: + properties: + address: + type: string + interval: + type: string + query: + type: string + type: object + wavefront: + properties: + address: + type: string + query: + type: string + type: object + web: + properties: + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + body: + type: string + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + jsonBody: + type: object + x-kubernetes-preserve-unknown-fields: true + jsonPath: + type: string + method: + type: string + timeoutSeconds: + format: int64 + type: integer + url: + type: string + required: + - url + type: object + type: object + successCondition: + type: string + required: + - name + - provider + type: object + type: array + templates: + items: + properties: + clusterScope: + type: boolean + templateName: + type: string + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: clusteranalysistemplates.argoproj.io +spec: + group: argoproj.io + names: + kind: ClusterAnalysisTemplate + listKind: ClusterAnalysisTemplateList + plural: clusteranalysistemplates + shortNames: + - cat + singular: clusteranalysistemplate + preserveUnknownFields: false + scope: Cluster + versions: + - additionalPrinterColumns: + - description: Time since resource was created + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + metrics: + items: + properties: + consecutiveErrorLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + consecutiveSuccessLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + count: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + failureCondition: + type: string + failureLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + inconclusiveLimit: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + initialDelay: + type: string + interval: + type: string + name: + type: string + provider: + properties: + cloudWatch: + properties: + interval: + type: string + metricDataQueries: + items: + properties: + expression: + type: string + id: + type: string + label: + type: string + metricStat: + properties: + metric: + properties: + dimensions: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + metricName: + type: string + namespace: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + stat: + type: string + unit: + type: string + type: object + period: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + returnData: + type: boolean + type: object + type: array + required: + - metricDataQueries + type: object + datadog: + properties: + aggregator: + enum: + - avg + - min + - max + - sum + - last + - percentile + - mean + - l2norm + - area + type: string + apiVersion: + default: v1 + enum: + - v1 + - v2 + type: string + formula: + type: string + interval: + default: 5m + type: string + queries: + additionalProperties: + type: string + type: object + query: + type: string + secretRef: + properties: + name: + type: string + namespaced: + type: boolean + type: object + type: object + graphite: + properties: + address: + type: string + query: + type: string + type: object + influxdb: + properties: + profile: + type: string + query: + type: string + type: object + job: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + backoffLimit: + format: int32 + type: integer + backoffLimitPerIndex: + format: int32 + type: integer + completionMode: + type: string + completions: + format: int32 + type: integer + manualSelector: + type: boolean + maxFailedIndexes: + format: int32 + type: integer + parallelism: + format: int32 + type: integer + podFailurePolicy: + properties: + rules: + items: + properties: + action: + type: string + onExitCodes: + properties: + containerName: + type: string + operator: + type: string + values: + items: + format: int32 + type: integer + type: array + x-kubernetes-list-type: set + required: + - operator + - values + type: object + onPodConditions: + items: + properties: + status: + type: string + type: + type: string + required: + - status + - type + type: object + type: array + x-kubernetes-list-type: atomic + required: + - action + type: object + type: array + x-kubernetes-list-type: atomic + required: + - rules + type: object + podReplacementPolicy: + type: string + selector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + suspend: + type: boolean + template: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + affinity: + properties: + nodeAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + preference: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + x-kubernetes-map-type: atomic + weight: + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + properties: + nodeSelectorTerms: + items: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + x-kubernetes-map-type: atomic + type: array + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + podAntiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + type: object + automountServiceAccountToken: + type: boolean + containers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + dnsConfig: + properties: + nameservers: + items: + type: string + type: array + options: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + searches: + items: + type: string + type: array + type: object + dnsPolicy: + type: string + enableServiceLinks: + type: boolean + ephemeralContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + targetContainerName: + type: string + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + hostAliases: + items: + properties: + hostnames: + items: + type: string + type: array + ip: + type: string + type: object + type: array + hostIPC: + type: boolean + hostNetwork: + type: boolean + hostPID: + type: boolean + hostUsers: + type: boolean + hostname: + type: string + imagePullSecrets: + items: + properties: + name: + type: string + type: object + x-kubernetes-map-type: atomic + type: array + initContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + nodeName: + type: string + nodeSelector: + additionalProperties: + type: string + type: object + x-kubernetes-map-type: atomic + os: + properties: + name: + type: string + required: + - name + type: object + overhead: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + preemptionPolicy: + type: string + priority: + format: int32 + type: integer + priorityClassName: + type: string + readinessGates: + items: + properties: + conditionType: + type: string + required: + - conditionType + type: object + type: array + resourceClaims: + items: + properties: + name: + type: string + source: + properties: + resourceClaimName: + type: string + resourceClaimTemplateName: + type: string + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + restartPolicy: + type: string + runtimeClassName: + type: string + schedulerName: + type: string + schedulingGates: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + securityContext: + properties: + fsGroup: + format: int64 + type: integer + fsGroupChangePolicy: + type: string + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + supplementalGroups: + items: + format: int64 + type: integer + type: array + sysctls: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + serviceAccount: + type: string + serviceAccountName: + type: string + setHostnameAsFQDN: + type: boolean + shareProcessNamespace: + type: boolean + subdomain: + type: string + terminationGracePeriodSeconds: + format: int64 + type: integer + tolerations: + items: + properties: + effect: + type: string + key: + type: string + operator: + type: string + tolerationSeconds: + format: int64 + type: integer + value: + type: string + type: object + type: array + topologySpreadConstraints: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + format: int32 + type: integer + minDomains: + format: int32 + type: integer + nodeAffinityPolicy: + type: string + nodeTaintsPolicy: + type: string + topologyKey: + type: string + whenUnsatisfiable: + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + x-kubernetes-list-map-keys: + - topologyKey + - whenUnsatisfiable + x-kubernetes-list-type: map + volumes: + x-kubernetes-preserve-unknown-fields: true + required: + - containers + type: object + type: object + ttlSecondsAfterFinished: + format: int32 + type: integer + required: + - template + type: object + required: + - spec + type: object + kayenta: + properties: + address: + type: string + application: + type: string + canaryConfigName: + type: string + configurationAccountName: + type: string + metricsAccountName: + type: string + scopes: + items: + properties: + controlScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - end + - region + - scope + - start + - step + type: object + experimentScope: + properties: + end: + type: string + region: + type: string + scope: + type: string + start: + type: string + step: + format: int64 + type: integer + required: + - end + - region + - scope + - start + - step + type: object + name: + type: string + required: + - controlScope + - experimentScope + - name + type: object + type: array + storageAccountName: + type: string + threshold: + properties: + marginal: + format: int64 + type: integer + pass: + format: int64 + type: integer + required: + - marginal + - pass + type: object + required: + - address + - application + - canaryConfigName + - configurationAccountName + - metricsAccountName + - scopes + - storageAccountName + - threshold + type: object + newRelic: + properties: + profile: + type: string + query: + type: string + timeout: + format: int64 + type: integer + required: + - query + type: object + plugin: + type: object + x-kubernetes-preserve-unknown-fields: true + prometheus: + properties: + address: + type: string + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + query: + type: string + rangeQuery: + properties: + end: + type: string + start: + type: string + step: + type: string + type: object + timeout: + format: int64 + type: integer + type: object + skywalking: + properties: + address: + type: string + interval: + type: string + query: + type: string + type: object + wavefront: + properties: + address: + type: string + query: + type: string + type: object + web: + properties: + authentication: + properties: + oauth2: + properties: + clientId: + type: string + clientSecret: + type: string + scopes: + items: + type: string + type: array + tokenUrl: + type: string + type: object + sigv4: + properties: + profile: + type: string + region: + type: string + roleArn: + type: string + type: object + type: object + body: + type: string + headers: + items: + properties: + key: + type: string + value: + type: string + required: + - key + - value + type: object + type: array + insecure: + type: boolean + jsonBody: + type: object + x-kubernetes-preserve-unknown-fields: true + jsonPath: + type: string + method: + type: string + timeoutSeconds: + format: int64 + type: integer + url: + type: string + required: + - url + type: object + type: object + successCondition: + type: string + required: + - name + - provider + type: object + type: array + templates: + items: + properties: + clusterScope: + type: boolean + templateName: + type: string + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: experiments.argoproj.io +spec: + group: argoproj.io + names: + kind: Experiment + listKind: ExperimentList + plural: experiments + shortNames: + - exp + singular: experiment + preserveUnknownFields: false + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Experiment status + jsonPath: .status.phase + name: Status + type: string + - description: Time since resource was created + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + analyses: + items: + properties: + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + secretKeyRef: + properties: + key: + type: string + name: + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + clusterScope: + type: boolean + name: + type: string + requiredForCompletion: + type: boolean + templateName: + type: string + required: + - name + - templateName + type: object + type: array + analysisRunMetadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + duration: + type: string + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + progressDeadlineSeconds: + format: int32 + type: integer + scaleDownDelaySeconds: + format: int32 + type: integer + templates: + items: + properties: + minReadySeconds: + format: int32 + type: integer + name: + type: string + replicas: + format: int32 + type: integer + selector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + service: + properties: + name: + type: string + type: object + template: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + affinity: + properties: + nodeAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + preference: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + x-kubernetes-map-type: atomic + weight: + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + properties: + nodeSelectorTerms: + items: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + x-kubernetes-map-type: atomic + type: array + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + podAntiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + type: object + automountServiceAccountToken: + type: boolean + containers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + dnsConfig: + properties: + nameservers: + items: + type: string + type: array + options: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + searches: + items: + type: string + type: array + type: object + dnsPolicy: + type: string + enableServiceLinks: + type: boolean + ephemeralContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + targetContainerName: + type: string + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + hostAliases: + items: + properties: + hostnames: + items: + type: string + type: array + ip: + type: string + type: object + type: array + hostIPC: + type: boolean + hostNetwork: + type: boolean + hostPID: + type: boolean + hostUsers: + type: boolean + hostname: + type: string + imagePullSecrets: + items: + properties: + name: + type: string + type: object + x-kubernetes-map-type: atomic + type: array + initContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + nodeName: + type: string + nodeSelector: + additionalProperties: + type: string + type: object + x-kubernetes-map-type: atomic + os: + properties: + name: + type: string + required: + - name + type: object + overhead: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + preemptionPolicy: + type: string + priority: + format: int32 + type: integer + priorityClassName: + type: string + readinessGates: + items: + properties: + conditionType: + type: string + required: + - conditionType + type: object + type: array + resourceClaims: + items: + properties: + name: + type: string + source: + properties: + resourceClaimName: + type: string + resourceClaimTemplateName: + type: string + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + restartPolicy: + type: string + runtimeClassName: + type: string + schedulerName: + type: string + schedulingGates: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + securityContext: + properties: + fsGroup: + format: int64 + type: integer + fsGroupChangePolicy: + type: string + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + supplementalGroups: + items: + format: int64 + type: integer + type: array + sysctls: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + serviceAccount: + type: string + serviceAccountName: + type: string + setHostnameAsFQDN: + type: boolean + shareProcessNamespace: + type: boolean + subdomain: + type: string + terminationGracePeriodSeconds: + format: int64 + type: integer + tolerations: + items: + properties: + effect: + type: string + key: + type: string + operator: + type: string + tolerationSeconds: + format: int64 + type: integer + value: + type: string + type: object + type: array + topologySpreadConstraints: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + format: int32 + type: integer + minDomains: + format: int32 + type: integer + nodeAffinityPolicy: + type: string + nodeTaintsPolicy: + type: string + topologyKey: + type: string + whenUnsatisfiable: + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + x-kubernetes-list-map-keys: + - topologyKey + - whenUnsatisfiable + x-kubernetes-list-type: map + volumes: + x-kubernetes-preserve-unknown-fields: true + required: + - containers + type: object + type: object + required: + - name + - selector + - template + type: object + type: array + terminate: + type: boolean + required: + - templates + type: object + status: + properties: + analysisRuns: + items: + properties: + analysisRun: + type: string + message: + type: string + name: + type: string + phase: + type: string + required: + - analysisRun + - name + - phase + type: object + type: array + availableAt: + format: date-time + type: string + conditions: + items: + properties: + lastTransitionTime: + format: date-time + type: string + lastUpdateTime: + format: date-time + type: string + message: + type: string + reason: + type: string + status: + type: string + type: + type: string + required: + - lastTransitionTime + - lastUpdateTime + - message + - reason + - status + - type + type: object + type: array + message: + type: string + phase: + type: string + templateStatuses: + items: + properties: + availableReplicas: + format: int32 + type: integer + collisionCount: + format: int32 + type: integer + lastTransitionTime: + format: date-time + type: string + message: + type: string + name: + type: string + podTemplateHash: + type: string + readyReplicas: + format: int32 + type: integer + replicas: + format: int32 + type: integer + serviceName: + type: string + status: + type: string + updatedReplicas: + format: int32 + type: integer + required: + - availableReplicas + - name + - readyReplicas + - replicas + - updatedReplicas + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: rollouts.argoproj.io +spec: + group: argoproj.io + names: + kind: Rollout + listKind: RolloutList + plural: rollouts + shortNames: + - ro + singular: rollout + preserveUnknownFields: false + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Number of desired pods + jsonPath: .spec.replicas + name: Desired + type: integer + - description: Total number of non-terminated pods targeted by this rollout + jsonPath: .status.replicas + name: Current + type: integer + - description: Total number of non-terminated pods targeted by this rollout that + have the desired template spec + jsonPath: .status.updatedReplicas + name: Up-to-date + type: integer + - description: Total number of available pods (ready for at least minReadySeconds) + targeted by this rollout + jsonPath: .status.availableReplicas + name: Available + type: integer + - description: Time since resource was created + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + analysis: + properties: + successfulRunHistoryLimit: + format: int32 + type: integer + unsuccessfulRunHistoryLimit: + format: int32 + type: integer + type: object + minReadySeconds: + format: int32 + type: integer + paused: + type: boolean + progressDeadlineAbort: + type: boolean + progressDeadlineSeconds: + format: int32 + type: integer + replicas: + format: int32 + type: integer + restartAt: + format: date-time + type: string + revisionHistoryLimit: + format: int32 + type: integer + rollbackWindow: + properties: + revisions: + format: int32 + type: integer + type: object + selector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + strategy: + properties: + blueGreen: + properties: + abortScaleDownDelaySeconds: + format: int32 + type: integer + activeMetadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + activeService: + type: string + antiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + properties: + weight: + format: int32 + type: integer + required: + - weight + type: object + requiredDuringSchedulingIgnoredDuringExecution: + type: object + type: object + autoPromotionEnabled: + type: boolean + autoPromotionSeconds: + format: int32 + type: integer + maxUnavailable: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + postPromotionAnalysis: + properties: + analysisRunMetadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + podTemplateHashValue: + type: string + type: object + required: + - name + type: object + type: array + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + templates: + items: + properties: + clusterScope: + type: boolean + templateName: + type: string + type: object + type: array + type: object + prePromotionAnalysis: + properties: + analysisRunMetadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + podTemplateHashValue: + type: string + type: object + required: + - name + type: object + type: array + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + templates: + items: + properties: + clusterScope: + type: boolean + templateName: + type: string + type: object + type: array + type: object + previewMetadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + previewReplicaCount: + format: int32 + type: integer + previewService: + type: string + scaleDownDelayRevisionLimit: + format: int32 + type: integer + scaleDownDelaySeconds: + format: int32 + type: integer + required: + - activeService + type: object + canary: + properties: + abortScaleDownDelaySeconds: + format: int32 + type: integer + analysis: + properties: + analysisRunMetadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + podTemplateHashValue: + type: string + type: object + required: + - name + type: object + type: array + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + startingStep: + format: int32 + type: integer + templates: + items: + properties: + clusterScope: + type: boolean + templateName: + type: string + type: object + type: array + type: object + antiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + properties: + weight: + format: int32 + type: integer + required: + - weight + type: object + requiredDuringSchedulingIgnoredDuringExecution: + type: object + type: object + canaryMetadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + canaryService: + type: string + dynamicStableScale: + type: boolean + maxSurge: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + maxUnavailable: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + minPodsPerReplicaSet: + format: int32 + type: integer + pingPong: + properties: + pingService: + type: string + pongService: + type: string + required: + - pingService + - pongService + type: object + scaleDownDelayRevisionLimit: + format: int32 + type: integer + scaleDownDelaySeconds: + format: int32 + type: integer + stableMetadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + stableService: + type: string + steps: + items: + properties: + analysis: + properties: + analysisRunMetadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + podTemplateHashValue: + type: string + type: object + required: + - name + type: object + type: array + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + measurementRetention: + items: + properties: + limit: + format: int32 + type: integer + metricName: + type: string + required: + - limit + - metricName + type: object + type: array + templates: + items: + properties: + clusterScope: + type: boolean + templateName: + type: string + type: object + type: array + type: object + experiment: + properties: + analyses: + items: + properties: + args: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + fieldRef: + properties: + fieldPath: + type: string + required: + - fieldPath + type: object + podTemplateHashValue: + type: string + type: object + required: + - name + type: object + type: array + clusterScope: + type: boolean + name: + type: string + requiredForCompletion: + type: boolean + templateName: + type: string + required: + - name + - templateName + type: object + type: array + analysisRunMetadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + dryRun: + items: + properties: + metricName: + type: string + required: + - metricName + type: object + type: array + duration: + type: string + templates: + items: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + name: + type: string + replicas: + format: int32 + type: integer + selector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + service: + properties: + name: + type: string + type: object + specRef: + type: string + weight: + format: int32 + type: integer + required: + - name + - specRef + type: object + type: array + required: + - templates + type: object + pause: + properties: + duration: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + type: object + plugin: + properties: + config: + type: object + x-kubernetes-preserve-unknown-fields: true + name: + type: string + required: + - name + type: object + setCanaryScale: + properties: + matchTrafficWeight: + type: boolean + replicas: + format: int32 + type: integer + weight: + format: int32 + type: integer + type: object + setHeaderRoute: + properties: + match: + items: + properties: + headerName: + type: string + headerValue: + properties: + exact: + type: string + prefix: + type: string + regex: + type: string + type: object + required: + - headerName + - headerValue + type: object + type: array + name: + type: string + type: object + setMirrorRoute: + properties: + match: + items: + properties: + headers: + additionalProperties: + properties: + exact: + type: string + prefix: + type: string + regex: + type: string + type: object + type: object + method: + properties: + exact: + type: string + prefix: + type: string + regex: + type: string + type: object + path: + properties: + exact: + type: string + prefix: + type: string + regex: + type: string + type: object + type: object + type: array + name: + type: string + percentage: + format: int32 + type: integer + required: + - name + type: object + setWeight: + format: int32 + type: integer + type: object + type: array + trafficRouting: + properties: + alb: + properties: + annotationPrefix: + type: string + ingress: + type: string + ingresses: + items: + type: string + type: array + rootService: + type: string + servicePort: + format: int32 + type: integer + stickinessConfig: + properties: + durationSeconds: + format: int64 + type: integer + enabled: + type: boolean + required: + - durationSeconds + - enabled + type: object + required: + - servicePort + type: object + ambassador: + properties: + mappings: + items: + type: string + type: array + required: + - mappings + type: object + apisix: + properties: + route: + properties: + name: + type: string + rules: + items: + type: string + type: array + required: + - name + type: object + type: object + appMesh: + properties: + virtualNodeGroup: + properties: + canaryVirtualNodeRef: + properties: + name: + type: string + required: + - name + type: object + stableVirtualNodeRef: + properties: + name: + type: string + required: + - name + type: object + required: + - canaryVirtualNodeRef + - stableVirtualNodeRef + type: object + virtualService: + properties: + name: + type: string + routes: + items: + type: string + type: array + required: + - name + type: object + type: object + istio: + properties: + destinationRule: + properties: + canarySubsetName: + type: string + name: + type: string + stableSubsetName: + type: string + required: + - canarySubsetName + - name + - stableSubsetName + type: object + virtualService: + properties: + name: + type: string + routes: + items: + type: string + type: array + tcpRoutes: + items: + properties: + port: + format: int64 + type: integer + type: object + type: array + tlsRoutes: + items: + properties: + port: + format: int64 + type: integer + sniHosts: + items: + type: string + type: array + type: object + type: array + required: + - name + type: object + virtualServices: + items: + properties: + name: + type: string + routes: + items: + type: string + type: array + tcpRoutes: + items: + properties: + port: + format: int64 + type: integer + type: object + type: array + tlsRoutes: + items: + properties: + port: + format: int64 + type: integer + sniHosts: + items: + type: string + type: array + type: object + type: array + required: + - name + type: object + type: array + type: object + managedRoutes: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + maxTrafficWeight: + format: int32 + type: integer + nginx: + properties: + additionalIngressAnnotations: + additionalProperties: + type: string + type: object + annotationPrefix: + type: string + canaryIngressAnnotations: + additionalProperties: + type: string + type: object + stableIngress: + type: string + stableIngresses: + items: + type: string + type: array + type: object + plugins: + type: object + x-kubernetes-preserve-unknown-fields: true + smi: + properties: + rootService: + type: string + trafficSplitName: + type: string + type: object + traefik: + properties: + weightedTraefikServiceName: + type: string + required: + - weightedTraefikServiceName + type: object + type: object + type: object + type: object + template: + properties: + metadata: + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + spec: + properties: + activeDeadlineSeconds: + format: int64 + type: integer + affinity: + properties: + nodeAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + preference: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + x-kubernetes-map-type: atomic + weight: + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + properties: + nodeSelectorTerms: + items: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchFields: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + type: object + x-kubernetes-map-type: atomic + type: array + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + podAntiAffinity: + properties: + preferredDuringSchedulingIgnoredDuringExecution: + items: + properties: + podAffinityTerm: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + weight: + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + requiredDuringSchedulingIgnoredDuringExecution: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + items: + type: string + type: array + topologyKey: + type: string + required: + - topologyKey + type: object + type: array + type: object + type: object + automountServiceAccountToken: + type: boolean + containers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + dnsConfig: + properties: + nameservers: + items: + type: string + type: array + options: + items: + properties: + name: + type: string + value: + type: string + type: object + type: array + searches: + items: + type: string + type: array + type: object + dnsPolicy: + type: string + enableServiceLinks: + type: boolean + ephemeralContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + targetContainerName: + type: string + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + hostAliases: + items: + properties: + hostnames: + items: + type: string + type: array + ip: + type: string + type: object + type: array + hostIPC: + type: boolean + hostNetwork: + type: boolean + hostPID: + type: boolean + hostUsers: + type: boolean + hostname: + type: string + imagePullSecrets: + items: + properties: + name: + type: string + type: object + x-kubernetes-map-type: atomic + type: array + initContainers: + items: + properties: + args: + items: + type: string + type: array + command: + items: + type: string + type: array + env: + items: + properties: + name: + type: string + value: + type: string + valueFrom: + properties: + configMapKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + properties: + apiVersion: + type: string + fieldPath: + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + properties: + containerName: + type: string + divisor: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + properties: + key: + type: string + name: + type: string + optional: + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + items: + properties: + configMapRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + type: string + secretRef: + properties: + name: + type: string + optional: + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + image: + type: string + imagePullPolicy: + type: string + lifecycle: + properties: + postStart: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + sleep: + properties: + seconds: + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + type: object + livenessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + name: + type: string + ports: + items: + properties: + containerPort: + format: int32 + type: integer + hostIP: + type: string + hostPort: + format: int32 + type: integer + name: + type: string + protocol: + default: TCP + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + resizePolicy: + items: + properties: + resourceName: + type: string + restartPolicy: + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + properties: + claims: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + x-kubernetes-preserve-unknown-fields: true + requests: + x-kubernetes-preserve-unknown-fields: true + type: object + restartPolicy: + type: string + securityContext: + properties: + allowPrivilegeEscalation: + type: boolean + capabilities: + properties: + add: + items: + type: string + type: array + drop: + items: + type: string + type: array + type: object + privileged: + type: boolean + procMount: + type: string + readOnlyRootFilesystem: + type: boolean + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + startupProbe: + properties: + exec: + properties: + command: + items: + type: string + type: array + type: object + failureThreshold: + format: int32 + type: integer + grpc: + properties: + port: + format: int32 + type: integer + service: + type: string + required: + - port + type: object + httpGet: + properties: + host: + type: string + httpHeaders: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + path: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + scheme: + type: string + required: + - port + type: object + initialDelaySeconds: + format: int32 + type: integer + periodSeconds: + format: int32 + type: integer + successThreshold: + format: int32 + type: integer + tcpSocket: + properties: + host: + type: string + port: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + format: int64 + type: integer + timeoutSeconds: + format: int32 + type: integer + type: object + stdin: + type: boolean + stdinOnce: + type: boolean + terminationMessagePath: + type: string + terminationMessagePolicy: + type: string + tty: + type: boolean + volumeDevices: + items: + properties: + devicePath: + type: string + name: + type: string + required: + - devicePath + - name + type: object + type: array + volumeMounts: + items: + properties: + mountPath: + type: string + mountPropagation: + type: string + name: + type: string + readOnly: + type: boolean + subPath: + type: string + subPathExpr: + type: string + required: + - mountPath + - name + type: object + type: array + workingDir: + type: string + required: + - name + type: object + type: array + nodeName: + type: string + nodeSelector: + additionalProperties: + type: string + type: object + x-kubernetes-map-type: atomic + os: + properties: + name: + type: string + required: + - name + type: object + overhead: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + preemptionPolicy: + type: string + priority: + format: int32 + type: integer + priorityClassName: + type: string + readinessGates: + items: + properties: + conditionType: + type: string + required: + - conditionType + type: object + type: array + resourceClaims: + items: + properties: + name: + type: string + source: + properties: + resourceClaimName: + type: string + resourceClaimTemplateName: + type: string + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + restartPolicy: + type: string + runtimeClassName: + type: string + schedulerName: + type: string + schedulingGates: + items: + properties: + name: + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + securityContext: + properties: + fsGroup: + format: int64 + type: integer + fsGroupChangePolicy: + type: string + runAsGroup: + format: int64 + type: integer + runAsNonRoot: + type: boolean + runAsUser: + format: int64 + type: integer + seLinuxOptions: + properties: + level: + type: string + role: + type: string + type: + type: string + user: + type: string + type: object + seccompProfile: + properties: + localhostProfile: + type: string + type: + type: string + required: + - type + type: object + supplementalGroups: + items: + format: int64 + type: integer + type: array + sysctls: + items: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array + windowsOptions: + properties: + gmsaCredentialSpec: + type: string + gmsaCredentialSpecName: + type: string + hostProcess: + type: boolean + runAsUserName: + type: string + type: object + type: object + serviceAccount: + type: string + serviceAccountName: + type: string + setHostnameAsFQDN: + type: boolean + shareProcessNamespace: + type: boolean + subdomain: + type: string + terminationGracePeriodSeconds: + format: int64 + type: integer + tolerations: + items: + properties: + effect: + type: string + key: + type: string + operator: + type: string + tolerationSeconds: + format: int64 + type: integer + value: + type: string + type: object + type: array + topologySpreadConstraints: + items: + properties: + labelSelector: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + format: int32 + type: integer + minDomains: + format: int32 + type: integer + nodeAffinityPolicy: + type: string + nodeTaintsPolicy: + type: string + topologyKey: + type: string + whenUnsatisfiable: + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + x-kubernetes-list-map-keys: + - topologyKey + - whenUnsatisfiable + x-kubernetes-list-type: map + volumes: + items: + x-kubernetes-preserve-unknown-fields: true + type: array + required: + - containers + type: object + type: object + workloadRef: + properties: + apiVersion: + type: string + kind: + type: string + name: + type: string + scaleDown: + type: string + type: object + type: object + status: + properties: + HPAReplicas: + format: int32 + type: integer + abort: + type: boolean + abortedAt: + format: date-time + type: string + alb: + properties: + canaryTargetGroup: + properties: + arn: + type: string + fullName: + type: string + name: + type: string + required: + - arn + - name + type: object + ingress: + type: string + loadBalancer: + properties: + arn: + type: string + fullName: + type: string + name: + type: string + required: + - arn + - name + type: object + stableTargetGroup: + properties: + arn: + type: string + fullName: + type: string + name: + type: string + required: + - arn + - name + type: object + type: object + albs: + items: + properties: + canaryTargetGroup: + properties: + arn: + type: string + fullName: + type: string + name: + type: string + required: + - arn + - name + type: object + ingress: + type: string + loadBalancer: + properties: + arn: + type: string + fullName: + type: string + name: + type: string + required: + - arn + - name + type: object + stableTargetGroup: + properties: + arn: + type: string + fullName: + type: string + name: + type: string + required: + - arn + - name + type: object + type: object + type: array + availableReplicas: + format: int32 + type: integer + blueGreen: + properties: + activeSelector: + type: string + postPromotionAnalysisRunStatus: + properties: + message: + type: string + name: + type: string + status: + type: string + required: + - name + - status + type: object + prePromotionAnalysisRunStatus: + properties: + message: + type: string + name: + type: string + status: + type: string + required: + - name + - status + type: object + previewSelector: + type: string + scaleUpPreviewCheckPoint: + type: boolean + type: object + canary: + properties: + currentBackgroundAnalysisRunStatus: + properties: + message: + type: string + name: + type: string + status: + type: string + required: + - name + - status + type: object + currentExperiment: + type: string + currentStepAnalysisRunStatus: + properties: + message: + type: string + name: + type: string + status: + type: string + required: + - name + - status + type: object + stablePingPong: + type: string + stepPluginStatuses: + items: + properties: + backoff: + type: string + disabled: + type: boolean + executions: + format: int32 + type: integer + finishedAt: + format: date-time + type: string + index: + format: int32 + type: integer + message: + type: string + name: + type: string + operation: + type: string + phase: + type: string + startedAt: + format: date-time + type: string + status: + type: object + x-kubernetes-preserve-unknown-fields: true + updatedAt: + format: date-time + type: string + required: + - index + - name + - operation + type: object + type: array + weights: + properties: + additional: + items: + properties: + podTemplateHash: + type: string + serviceName: + type: string + weight: + format: int32 + type: integer + required: + - weight + type: object + type: array + canary: + properties: + podTemplateHash: + type: string + serviceName: + type: string + weight: + format: int32 + type: integer + required: + - weight + type: object + stable: + properties: + podTemplateHash: + type: string + serviceName: + type: string + weight: + format: int32 + type: integer + required: + - weight + type: object + verified: + type: boolean + required: + - canary + - stable + type: object + type: object + collisionCount: + format: int32 + type: integer + conditions: + items: + properties: + lastTransitionTime: + format: date-time + type: string + lastUpdateTime: + format: date-time + type: string + message: + type: string + reason: + type: string + status: + type: string + type: + type: string + required: + - lastTransitionTime + - lastUpdateTime + - message + - reason + - status + - type + type: object + type: array + controllerPause: + type: boolean + currentPodHash: + type: string + currentStepHash: + type: string + currentStepIndex: + format: int32 + type: integer + message: + type: string + observedGeneration: + type: string + pauseConditions: + items: + properties: + reason: + type: string + startTime: + format: date-time + type: string + required: + - reason + - startTime + type: object + type: array + phase: + type: string + promoteFull: + type: boolean + readyReplicas: + format: int32 + type: integer + replicas: + format: int32 + type: integer + restartedAt: + format: date-time + type: string + selector: + type: string + stableRS: + type: string + updatedReplicas: + format: int32 + type: integer + workloadObservedGeneration: + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + scale: + labelSelectorPath: .status.selector + specReplicasPath: .spec.replicas + statusReplicasPath: .status.HPAReplicas + status: {} diff --git a/scripts/kind/kind-config.yaml b/scripts/kind/kind-config.yaml new file mode 100644 index 00000000..8afdaab3 --- /dev/null +++ b/scripts/kind/kind-config.yaml @@ -0,0 +1,39 @@ +######################################################################## +# https://kind.sigs.k8s.io/docs/user/configuration/ +######################################################################## +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: kagent + +# network configuration +networking: + # WARNING: It is _strongly_ recommended that you keep this the default + # (127.0.0.1) for security reasons. However, it is possible to change this. + apiServerAddress: "127.0.0.1" + # By default, the API server listens on a random open port. + # You may choose a specific port but probably don't need to in most cases. + # Using a random port makes it easier to spin up multiple clusters. + # apiServerPort: 6443 + +# this may be used to e.g. disable beta / alpha APIs. +runtimeConfig: + "api/alpha": "false" + +# add to the apiServer certSANs the name of the docker (dind) service in order to be able to reach the cluster through it +kubeadmConfigPatchesJSON6902: + - group: kubeadm.k8s.io + version: v1beta2 + kind: ClusterConfiguration + patch: | + - op: add + path: /apiServer/certSANs/- + value: docker + +# this is the default configuration for nodes +nodes: + - role: control-plane + extraPortMappings: + - containerPort: 30884 + hostPort: 30884 + - containerPort: 30885 + hostPort: 30885 \ No newline at end of file diff --git a/scripts/kind/test-values-e2e.yaml b/scripts/kind/test-values-e2e.yaml new file mode 100644 index 00000000..9460dde2 --- /dev/null +++ b/scripts/kind/test-values-e2e.yaml @@ -0,0 +1,18 @@ +service: + type: NodePort + ports: + tools: + nodePort: 30885 + +tools: + image: + registry: cr.kagent.dev + +otel: + tracing: + enabled: true + exporter: + otlp: + endpoint: http://host.docker.internal:4317 + timeout: 15 + insecure: true \ No newline at end of file diff --git a/scripts/kind/test-values.yaml b/scripts/kind/test-values.yaml new file mode 100644 index 00000000..f06a7cc0 --- /dev/null +++ b/scripts/kind/test-values.yaml @@ -0,0 +1,18 @@ +service: + type: NodePort + ports: + tools: + nodePort: 30884 + +tools: + image: + registry: cr.kagent.dev + +otel: + tracing: + enabled: true + exporter: + otlp: + endpoint: http://host.docker.internal:4317 + timeout: 15 + insecure: true \ No newline at end of file diff --git a/test/e2e/cli_test.go b/test/e2e/cli_test.go new file mode 100644 index 00000000..73df2a85 --- /dev/null +++ b/test/e2e/cli_test.go @@ -0,0 +1,633 @@ +package e2e + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Test suite setup +var _ = Describe("KAgent Tools E2E Tests", func() { + var ( + ctx context.Context + cancel context.CancelFunc + ) + + BeforeEach(func() { + ctx, cancel = context.WithTimeout(context.Background(), 60*time.Second) + + // Set OTEL environment variables for testing + os.Setenv("OTEL_SERVICE_NAME", "kagent-tools-e2e-test") + os.Setenv("LOG_LEVEL", "debug") + }) + + AfterEach(func() { + if cancel != nil { + cancel() + } + os.Unsetenv("OTEL_SERVICE_NAME") + os.Unsetenv("LOG_LEVEL") + }) + + Describe("HTTP Server Tests", func() { + It("should start and stop HTTP server successfully", func() { + config := TestServerConfig{ + Port: 8085, + Stdio: false, + Timeout: 60 * time.Second, + } + + server := NewTestServer(config) + + // Start server + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Test health endpoint + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", config.Port)) + Expect(err).NotTo(HaveOccurred(), "Health endpoint should be accessible") + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + resp.Body.Close() + + // Check server output + output := server.GetOutput() + Expect(output).To(ContainSubstring("Running KAgent Tools Server")) + Expect(output).To(ContainSubstring(fmt.Sprintf(":%d", config.Port))) + + // Stop server + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + + // Wait for server to fully shutdown + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + err = server.waitForShutdown(shutdownCtx, config.Port) + Expect(err).NotTo(HaveOccurred(), "Server should shut down completely") + }) + + It("should start server with specific tools", func() { + config := TestServerConfig{ + Port: 8086, + Tools: []string{"utils", "k8s"}, + Stdio: false, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + + // Start server + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Check server output for tool registration + output := server.GetOutput() + Expect(output).To(ContainSubstring("RegisterTools initialized")) + Expect(output).To(ContainSubstring("utils")) + Expect(output).To(ContainSubstring("k8s")) + + // Stop server + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + + It("should start server with all tools enabled", func() { + config := TestServerConfig{ + Port: 8087, + Stdio: false, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + + // Start server + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Check server output for all tools registration + output := server.GetOutput() + Expect(output).To(ContainSubstring("RegisterTools initialized")) + Expect(output).To(ContainSubstring("Running KAgent Tools Server")) + + // Stop server + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + + It("should start server with kubeconfig parameter", func() { + // Create a temporary kubeconfig file + tempDir := GinkgoT().TempDir() + kubeconfigPath := filepath.Join(tempDir, "kubeconfig") + + kubeconfigContent := `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://test-cluster + name: test-cluster +contexts: +- context: + cluster: test-cluster + user: test-user + name: test-context +current-context: test-context +users: +- name: test-user + user: + token: test-token +` + + err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0644) + Expect(err).NotTo(HaveOccurred(), "Should create temporary kubeconfig file") + + config := TestServerConfig{ + Port: 8088, + Kubeconfig: kubeconfigPath, + Stdio: false, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + + // Start server + err = server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Check server output for kubeconfig setting + output := server.GetOutput() + Expect(output).To(ContainSubstring("RegisterTools initialized")) + Expect(output).To(ContainSubstring("Running KAgent Tools Server")) + + // Stop server + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + + It("should handle invalid tool names gracefully", func() { + config := TestServerConfig{ + Port: 18190, + Tools: []string{"invalid-tool", "utils"}, + Stdio: false, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + + // Start server + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start even with invalid tools") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Check server output for error about invalid tool + output := server.GetOutput() + Expect(output).To(ContainSubstring("Unknown tool specified")) + Expect(output).To(ContainSubstring("invalid-tool")) + + // Valid tools should still be registered + Expect(output).To(ContainSubstring("RegisterTools initialized")) + Expect(output).To(ContainSubstring("utils")) + + // Stop server + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + + It("should handle graceful shutdown", func() { + config := TestServerConfig{ + Port: 8100, + Stdio: false, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + + // Start server + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Test health endpoint to ensure server is fully ready + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", config.Port)) + Expect(err).NotTo(HaveOccurred(), "Health endpoint should be accessible") + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + resp.Body.Close() + + // Stop server and measure shutdown time + start := time.Now() + err = server.Stop() + duration := time.Since(start) + + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + Expect(duration).To(BeNumerically("<", 10*time.Second), "Shutdown should complete within reasonable time") + + // Check server output for graceful shutdown + output := server.GetOutput() + Expect(output).To(ContainSubstring("Running KAgent Tools Server")) + + // Wait for server to fully shutdown + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + err = server.waitForShutdown(shutdownCtx, config.Port) + Expect(err).NotTo(HaveOccurred(), "Server should shut down completely") + }) + + It("should handle concurrent requests", func() { + config := TestServerConfig{ + Port: 8088, + Tools: []string{"utils", "k8s"}, + Stdio: false, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Create multiple concurrent requests + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", config.Port)) + Expect(err).NotTo(HaveOccurred(), "Concurrent request %d should succeed", id) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + resp.Body.Close() + }(i) + } + + wg.Wait() + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + + It("should expose metrics endpoint", func() { + config := TestServerConfig{ + Port: 18190, + Tools: []string{"utils"}, + Stdio: false, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Test metrics endpoint + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/metrics", config.Port)) + Expect(err).NotTo(HaveOccurred(), "Metrics endpoint should be accessible") + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + // Read and verify metrics content + body, err := io.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + resp.Body.Close() + + metricsContent := string(body) + Expect(metricsContent).To(ContainSubstring("go_")) + Expect(metricsContent).To(ContainSubstring("process_")) + + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + }) + + Describe("STDIO Server Tests", func() { + It("should start STDIO server successfully", func() { + config := TestServerConfig{ + Stdio: true, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + + // Start server + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Check server output for STDIO mode + output := server.GetOutput() + Expect(output).To(ContainSubstring("Running KAgent Tools Server STDIO")) + + // Stop server + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + }) + + Describe("Tool Registration Tests", func() { + It("should register single tool correctly", func() { + config := TestServerConfig{ + Port: 8087, + Tools: []string{"k8s"}, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Verify registered tools + output := server.GetOutput() + Expect(output).To(ContainSubstring("Running KAgent Tools Server")) + Expect(output).To(ContainSubstring("k8s")) + + // Test health endpoint + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", config.Port)) + Expect(err).NotTo(HaveOccurred(), "Health endpoint should be accessible") + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + resp.Body.Close() + + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + + It("should register multiple tools correctly", func() { + config := TestServerConfig{ + Port: 8088, + Tools: []string{"k8s", "prometheus", "utils"}, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Verify registered tools + output := server.GetOutput() + Expect(output).To(ContainSubstring("Running KAgent Tools Server")) + for _, tool := range []string{"k8s", "prometheus", "utils"} { + Expect(output).To(ContainSubstring(tool)) + } + + // Test health endpoint + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", config.Port)) + Expect(err).NotTo(HaveOccurred(), "Health endpoint should be accessible") + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + resp.Body.Close() + + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + + It("should register all tools implicitly", func() { + config := TestServerConfig{ + Port: 18190, + Tools: []string{}, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Verify all tools are registered + output := server.GetOutput() + Expect(output).To(ContainSubstring("RegisterTools initialized")) + Expect(output).To(ContainSubstring("Running KAgent Tools Server")) + + // Test health endpoint + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", config.Port)) + Expect(err).NotTo(HaveOccurred(), "Health endpoint should be accessible") + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + resp.Body.Close() + + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + }) + + Describe("Error Handling Tests", func() { + It("should handle malformed requests gracefully", func() { + config := TestServerConfig{ + Port: 8089, + Tools: []string{"utils"}, + Stdio: false, + Timeout: 10 * time.Second, + } + + server := NewTestServer(config) + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Test malformed request + req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/nonexistent", config.Port), strings.NewReader("invalid json")) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) + resp.Body.Close() + + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + }) + + Describe("Environment and Configuration Tests", func() { + It("should handle environment variables correctly", func() { + // Set environment variables + originalEnv := os.Environ() + defer func() { + os.Clearenv() + for _, env := range originalEnv { + parts := strings.SplitN(env, "=", 2) + if len(parts) == 2 { + os.Setenv(parts[0], parts[1]) + } + } + }() + + os.Setenv("LOG_LEVEL", "info") + os.Setenv("OTEL_SERVICE_NAME", "test-kagent-tools") + + config := TestServerConfig{ + Port: 18195, + Stdio: false, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + + // Start server + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Check server output + output := server.GetOutput() + Expect(output).To(ContainSubstring("Starting kagent-tools-server")) + + // Stop server + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + + It("should validate server binary exists and is executable", func() { + // Check if server binary exists + binaryName := getBinaryName() + binaryPath := fmt.Sprintf("../../bin/%s", binaryName) + _, err := os.Stat(binaryPath) + if os.IsNotExist(err) { + Skip("Server binary not found, skipping test. Run 'make build' first.") + } + Expect(err).NotTo(HaveOccurred(), "Server binary should exist") + + // Test --help flag + helpCtx, helpCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer helpCancel() + + cmd := exec.CommandContext(helpCtx, binaryPath, "--help") + output, err := cmd.CombinedOutput() + Expect(err).NotTo(HaveOccurred(), "Server should respond to --help flag") + + outputStr := string(output) + Expect(outputStr).To(ContainSubstring("KAgent tool server")) + Expect(outputStr).To(ContainSubstring("--port")) + Expect(outputStr).To(ContainSubstring("--stdio")) + Expect(outputStr).To(ContainSubstring("--tools")) + Expect(outputStr).To(ContainSubstring("--kubeconfig")) + }) + }) + + Describe("Concurrent Server Instances", func() { + It("should run multiple server instances concurrently", func() { + var wg sync.WaitGroup + numServers := 3 + servers := make([]*TestServer, numServers) + + // Start multiple servers on different ports + for i := 0; i < numServers; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + + config := TestServerConfig{ + Port: 18092 + index, + Tools: []string{"utils"}, + Stdio: false, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + servers[index] = server + + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server %d should start successfully", index) + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Test health endpoint + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", config.Port)) + Expect(err).NotTo(HaveOccurred(), "Health endpoint should be accessible for server %d", index) + if resp != nil { + resp.Body.Close() + } + }(i) + } + + wg.Wait() + + // Stop all servers + for i, server := range servers { + if server != nil { + err := server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server %d should stop gracefully", i) + } + } + }) + }) + + Describe("Telemetry Tests", func() { + It("should initialize telemetry correctly", func() { + config := TestServerConfig{ + Port: 18092, + Tools: []string{"utils"}, + Timeout: 30 * time.Second, + } + + server := NewTestServer(config) + err := server.Start(ctx, config) + Expect(err).NotTo(HaveOccurred(), "Server should start successfully") + + // Wait for server to be ready + time.Sleep(3 * time.Second) + + // Check server output for telemetry initialization + output := server.GetOutput() + Expect(output).To(ContainSubstring("Starting kagent-tools-server")) + + // Make a request to generate telemetry + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/health", config.Port)) + Expect(err).NotTo(HaveOccurred(), "Health endpoint should be accessible") + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + resp.Body.Close() + + // Check server output for successful startup + output = server.GetOutput() + Expect(output).To(ContainSubstring("Running KAgent Tools Server")) + + err = server.Stop() + Expect(err).NotTo(HaveOccurred(), "Server should stop gracefully") + }) + }) +}) + +// Helper functions for test setup +func init() { + // Ensure the binary exists before running tests + binaryName := getBinaryName() + binaryPath := fmt.Sprintf("../../bin/%s", binaryName) + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + // Try to build the binary + cmd := exec.Command("make", "build") + cmd.Dir = "../.." + if err := cmd.Run(); err != nil { + panic(fmt.Sprintf("Failed to build server binary: %v", err)) + } + } +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go new file mode 100644 index 00000000..1f957924 --- /dev/null +++ b/test/e2e/e2e_test.go @@ -0,0 +1,13 @@ +package e2e + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "testing" +) + +// TestE2EK8s is the main test runner for Kubernetes E2E tests +func TestE2EK8s(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Tools E2E Suite") +} diff --git a/test/e2e/helpers_test.go b/test/e2e/helpers_test.go new file mode 100644 index 00000000..8f6d5221 --- /dev/null +++ b/test/e2e/helpers_test.go @@ -0,0 +1,606 @@ +package e2e + +import ( + "bufio" + "context" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "time" + + "github.com/kagent-dev/tools/internal/commands" + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// getBinaryName returns the platform-specific binary name +func getBinaryName() string { + osName := runtime.GOOS + archName := runtime.GOARCH + return fmt.Sprintf("kagent-tools-%s-%s", osName, archName) +} + +// TestServerConfig holds configuration for server tests +type TestServerConfig struct { + Port int + Tools []string + Kubeconfig string + Stdio bool + Timeout time.Duration +} + +// TestServer represents a test server instance +type TestServer struct { + cmd *exec.Cmd + port int + stdio bool + cancel context.CancelFunc + done chan struct{} + output strings.Builder + mu sync.RWMutex +} + +// NewTestServer creates a new test server instance +func NewTestServer(config TestServerConfig) *TestServer { + return &TestServer{ + port: config.Port, + stdio: config.Stdio, + done: make(chan struct{}), + } +} + +// Start starts the test server +func (ts *TestServer) Start(ctx context.Context, config TestServerConfig) error { + ts.mu.Lock() + defer ts.mu.Unlock() + + // Build command arguments + args := []string{} + if config.Stdio { + args = append(args, "--stdio") + } else { + args = append(args, "--port", fmt.Sprintf("%d", config.Port)) + } + + if len(config.Tools) > 0 { + args = append(args, "--tools", strings.Join(config.Tools, ",")) + } + + if config.Kubeconfig != "" { + args = append(args, "--kubeconfig", config.Kubeconfig) + } + + // Create context with cancellation + ctx, cancel := context.WithCancel(ctx) + ts.cancel = cancel + + // Start server process + binaryName := getBinaryName() + ts.cmd = exec.CommandContext(ctx, fmt.Sprintf("../../bin/%s", binaryName), args...) + ts.cmd.Env = append(os.Environ(), "LOG_LEVEL=debug") + + // Set up output capture + stdout, err := ts.cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderr, err := ts.cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + // Start the command + if err := ts.cmd.Start(); err != nil { + return fmt.Errorf("failed to start server: %w", err) + } + + // Start goroutines to capture output + go ts.captureOutput(stdout, "STDOUT") + go ts.captureOutput(stderr, "STDERR") + + // Wait for server to start + if !config.Stdio { + return ts.waitForHTTPServer(ctx, config.Timeout) + } + + return nil +} + +// Stop stops the test server +func (ts *TestServer) Stop() error { + ts.mu.Lock() + defer ts.mu.Unlock() + + if ts.cancel != nil { + ts.cancel() + } + + if ts.cmd != nil && ts.cmd.Process != nil { + // Send interrupt signal for graceful shutdown + if err := ts.cmd.Process.Signal(os.Interrupt); err != nil { + // If interrupt fails, kill the process + _ = ts.cmd.Process.Kill() + } + + // Wait for process to exit with timeout + done := make(chan error, 1) + go func() { + done <- ts.cmd.Wait() + }() + + select { + case <-done: + // Process exited + case <-time.After(8 * time.Second): // Increased timeout + // Timeout, force kill + _ = ts.cmd.Process.Kill() + // Wait a bit more for force kill to complete + select { + case <-done: + case <-time.After(2 * time.Second): + // Force kill timeout, continue anyway + } + } + } + + // Signal done and wait for goroutines to exit + if ts.done != nil { + close(ts.done) + } + + // Give goroutines time to exit + time.Sleep(100 * time.Millisecond) + + return nil +} + +// MCPClient represents a client for communicating with the MCP server using the official mcp-go client +type MCPClient struct { + client *client.Client + log *slog.Logger +} + +// InstallKAgentTools installs KAgent Tools using helm in the specified namespace +func InstallKAgentTools(namespace string, releaseName string) { + // Use longer timeout for helm installation as it can take time to pull images + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + log := slog.Default() + By("Installing KAgent Tools in namespace " + namespace) + log.Info("Installing KAgent Tools", "namespace", namespace) + + // First, try to uninstall any existing release to clean up + log.Info("Cleaning up any existing release", "release", releaseName, "namespace", namespace) + _, _ = commands.NewCommandBuilder("helm"). + WithArgs("uninstall", releaseName). + WithArgs("--namespace", namespace). + WithArgs("--ignore-not-found"). + WithCache(false). + Execute(ctx) + + // install crd scripts/kind/crd-argo.yaml + By("Installing CRDs for KAgent Tools") + _, err := commands.NewCommandBuilder("kubectl"). + WithArgs("apply", "-f", "../../scripts/kind/crd-argo.yaml"). + WithArgs("--namespace", namespace). + WithCache(false). // Don't cache CRD installation + Execute(ctx) + Expect(err).ToNot(HaveOccurred(), "Failed to install CRDs: %v", err) + + // Install KAgent Tools using helm with unique release name + // Use absolute path from project root + output, err := commands.NewCommandBuilder("helm"). + WithArgs("install", releaseName, "../../helm/kagent-tools"). + WithArgs("--namespace", namespace). + WithArgs("-f"). + WithArgs("../../scripts/kind/test-values-e2e.yaml"). + WithArgs("--create-namespace"). + WithArgs("--debug"). + WithArgs("--wait"). + WithArgs("--timeout=1m"). + WithCache(false). // Don't cache helm installation + Execute(ctx) + + Expect(err).ToNot(HaveOccurred(), "Failed to install KAgent Tools: %v %v", err, output) + log.Info("KAgent Tools installation completed", "namespace", namespace, "output", output) + + // Verify the installation by checking if pods are running + By("Verifying KAgent Tools pods are running") + log.Info("Verifying KAgent Tools pods", "namespace", namespace) + + Eventually(func() bool { + ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancel() + + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "pods", "-n", namespace, "-l", "app.kubernetes.io/instance="+releaseName, "-o", "jsonpath={.items[*].status.phase}"). + Execute(ctx) + + if err != nil { + log.Error("Failed to get pod status", "error", err) + return false + } + + log.Info("Pod status check", "namespace", namespace, "output", output) + // Check if all pods are in Running state + return output == "Running" || (len(output) > 0 && !contains(output, "Pending") && !contains(output, "Failed")) + }, 60*time.Second, 5*time.Second).Should(BeTrue(), "KAgent Tools pods should be running") + + log.Info("KAgent Tools pods are running", "namespace", namespace) + //validate service nodePort == 30885 + By("Validating KAgent Tools service is accessible") + nodePort, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "svc", "-n", namespace, "-o", "jsonpath={.items[0].spec.ports[0].nodePort}"). + Execute(ctx) + Expect(err).ToNot(HaveOccurred(), "Failed to get service nodePort: %v", err) + Expect(nodePort).To(Equal("30885")) +} + +// GetMCPClient creates a new MCP client configured for the e2e test environment using the official mcp-go client +func GetMCPClient() (*MCPClient, error) { + // Create HTTP transport for the MCP server with timeout long enough for operations like Istio installation + httpTransport, err := transport.NewStreamableHTTP("http://127.0.0.1:30885/mcp", transport.WithHTTPTimeout(180*time.Second)) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP transport: %w", err) + } + + // Create the official MCP client + mcpClient := client.NewClient(httpTransport) + + // Start the client + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if err := mcpClient.Start(ctx); err != nil { + return nil, fmt.Errorf("failed to start MCP client: %w", err) + } + + // Initialize the client + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: "e2e-test-client", + Version: "1.0.0", + } + initRequest.Params.Capabilities = mcp.ClientCapabilities{} + + _, err = mcpClient.Initialize(ctx, initRequest) + if err != nil { + return nil, fmt.Errorf("failed to initialize MCP client: %w", err) + } + + mcpHelper := &MCPClient{ + client: mcpClient, + log: slog.Default(), + } + + // Validate connection by listing tools + tools, err := mcpHelper.listTools() + if len(tools) == 0 { + return nil, fmt.Errorf("no tools found in MCP server: %w", err) + } + slog.Default().Info("MCP Client created", "baseURL", "http://127.0.0.1:30885/mcp", "tools", len(tools)) + return mcpHelper, err +} + +// listTools calls the tools/list method to get available tools +func (c *MCPClient) listTools() ([]interface{}, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + request := mcp.ListToolsRequest{} + result, err := c.client.ListTools(ctx, request) + if err != nil { + return nil, err + } + + // Convert tools to interface{} slice for compatibility + tools := make([]interface{}, len(result.Tools)) + for i, tool := range result.Tools { + tools[i] = tool + } + + return tools, nil +} + +// k8sListResources calls the k8s_get_resources tool +func (c *MCPClient) k8sListResources(resourceType string) (interface{}, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + type K8sArgs struct { + ResourceType string `json:"resource_type"` + Output string `json:"output"` + } + + arguments := K8sArgs{ + ResourceType: resourceType, + Output: "json", + } + + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "k8s_get_resources", + Arguments: arguments, + }, + } + + result, err := c.client.CallTool(ctx, request) + if err != nil { + return nil, err + } + if result.IsError { + return nil, fmt.Errorf("tool call failed: %s", result.Content) + } + return result, nil +} + +// helmListReleases calls the helm_list_releases tool +func (c *MCPClient) helmListReleases() (interface{}, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + type HelmArgs struct { + AllNamespaces string `json:"all_namespaces"` + Output string `json:"output"` + } + + arguments := HelmArgs{ + AllNamespaces: "true", + Output: "json", + } + + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "helm_list_releases", + Arguments: arguments, + }, + } + + result, err := c.client.CallTool(ctx, request) + if err != nil { + return nil, err + } + if result.IsError { + return nil, fmt.Errorf("tool call failed: %s", result.Content) + } + return result, nil +} + +// istioInstall calls the istio_install_istio tool +func (c *MCPClient) istioInstall(profile string) (interface{}, error) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) // Istio install can take time + defer cancel() + + type IstioArgs struct { + Profile string `json:"profile"` + } + + arguments := IstioArgs{ + Profile: profile, + } + + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "istio_install_istio", + Arguments: arguments, + }, + } + + result, err := c.client.CallTool(ctx, request) + if err != nil { + return nil, err + } + if result.IsError { + return nil, fmt.Errorf("tool call failed: %s", result.Content) + } + return result, nil +} + +// argoRolloutsList calls the argo_rollouts_get tool to list rollouts +func (c *MCPClient) argoRolloutsList(namespace string) (interface{}, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + type ArgoArgs struct { + Namespace string `json:"namespace"` + Output string `json:"output"` + } + + arguments := ArgoArgs{ + Namespace: namespace, + Output: "json", + } + + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "argo_rollouts_list", + Arguments: arguments, + }, + } + + result, err := c.client.CallTool(ctx, request) + if err != nil { + return nil, err + } + if result.IsError { + return nil, fmt.Errorf("tool call failed: %s", result.Content) + } + return result, nil +} + +// ciliumStatus calls the cilium_status_and_version tool +func (c *MCPClient) ciliumStatus() (interface{}, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + request := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "cilium_status_and_version", + Arguments: nil, + }, + } + + result, err := c.client.CallTool(ctx, request) + if err != nil { + return nil, err + } + return result, nil +} + +// Constants for default test values +const ( + DefaultReleaseName = "kagent-tools-e2e" + DefaultTestNamespace = "kagent-tools-e2e" + DefaultTimeout = 60 * time.Second // Increased for more realistic timeouts +) + +// CreateNamespace creates a new Kubernetes namespace +func CreateNamespace(namespace string) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + log := slog.Default() + By("Creating namespace " + namespace) + log.Info("Creating namespace", "namespace", namespace) + + // First, check if the namespace already exists + _, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "namespace", namespace). + WithCache(false). + Execute(ctx) + + if err == nil { + log.Info("Namespace already exists, skipping creation", "namespace", namespace) + return + } + + // Create the namespace using kubectl + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("create", "namespace", namespace). + WithCache(false). // Don't cache namespace creation + Execute(ctx) + + // If it's an AlreadyExists error, that's fine - treat it as success + if err != nil && strings.Contains(err.Error(), "AlreadyExists") { + log.Info("Namespace already exists, continuing", "namespace", namespace) + return + } + + Expect(err).ToNot(HaveOccurred(), "Failed to create namespace: %v", err) + log.Info("Namespace creation completed", "namespace", namespace, "output", output) +} + +// DeleteNamespace deletes a Kubernetes namespace +func DeleteNamespace(namespace string) { + // Use longer timeout for namespace deletion as it can take more time + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + log := slog.Default() + By("Deleting namespace " + namespace) + log.Info("Deleting namespace", "namespace", namespace) + + // Delete the namespace using kubectl + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("delete", "namespace", namespace, "--ignore-not-found=true", "--wait=false"). + WithCache(false). // Don't cache namespace deletion + Execute(ctx) + + Expect(err).ToNot(HaveOccurred(), "Failed to delete namespace: %v", err) + log.Info("Namespace deletion completed", "namespace", namespace, "output", output) +} + +// contains checks if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsHelper(s, substr))) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// waitForHTTPServer waits for the HTTP server to become available +func (ts *TestServer) waitForHTTPServer(ctx context.Context, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + url := fmt.Sprintf("http://localhost:%d/health", ts.port) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for server to start") + case <-ticker.C: + resp, err := http.Get(url) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} + +// waitForShutdown waits for the HTTP server to become unavailable +func (ts *TestServer) waitForShutdown(ctx context.Context, port int) error { + url := fmt.Sprintf("http://localhost:%d/health", port) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for server to shutdown") + case <-ticker.C: + _, err := http.Get(url) + if err != nil { + // Server is not accessible, shutdown complete + return nil + } + } + } +} + +// GetOutput returns the captured output +func (ts *TestServer) GetOutput() string { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.output.String() +} + +// captureOutput captures output from the server +func (ts *TestServer) captureOutput(reader io.Reader, prefix string) { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + select { + case <-ts.done: + // Shutdown signal received, exit goroutine + return + default: + line := scanner.Text() + ts.mu.Lock() + ts.output.WriteString(fmt.Sprintf("[%s] %s\n", prefix, line)) + ts.mu.Unlock() + } + } +} diff --git a/test/e2e/k8s_test.go b/test/e2e/k8s_test.go new file mode 100644 index 00000000..e90b6ddb --- /dev/null +++ b/test/e2e/k8s_test.go @@ -0,0 +1,151 @@ +package e2e + +import ( + "context" + "fmt" + "github.com/kagent-dev/tools/internal/commands" + "github.com/kagent-dev/tools/internal/logger" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +/* +K8s E2E Tests +These tests are used to test the Kubernetes integration of the KAgent Tools. +They are run in a Kubernetes cluster and have working in-cluster resources. + +They test the following: +- KAgent Tools can be installed in a Kubernetes cluster +- KAgent Tools k8s can list all resources in the cluster +- KAgent Tools helm can list all releases in the cluster +- KAgent Tools istioctl can install istio in the cluster +- KAgent Tools cillium can install cillium in the cluster +*/ + +var _ = Describe("KAgent Tools Kubernetes E2E Tests", Ordered, func() { + + var err error + var client *MCPClient + var log = logger.Get() + var namespace = DefaultTestNamespace + var releaseName = DefaultReleaseName + + BeforeAll(func() { + log.Info("Starting KAgent Tools E2E tests") + // Create new namespace + CreateNamespace(namespace) + // Install kagent tools + InstallKAgentTools(namespace, releaseName) + + client, err = GetMCPClient() + Expect(err).ToNot(HaveOccurred(), "Failed to get MCP client: %v", err) + }) + + AfterAll(func() { + log.Info("Cleaning up KAgent Tools E2E tests", "namespace", namespace) + // Delete namespace + if namespace != "" { + DeleteNamespace(namespace) + } + }) + + Describe("KAgent Tools Deployment", func() { + It("should have kagent-tools pods running", func() { + ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancel() + + log.Info("Checking if kagent-tools pods are running", "namespace", namespace) + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "pods", "-n", namespace, "-l", "app.kubernetes.io/instance="+releaseName, "-o", "json"). + Execute(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(output).ToNot(BeEmpty()) + log.Info("Successfully verified kagent-tools pods", "namespace", namespace) + }) + + It("should have kagent-tools service accessible", func() { + ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancel() + + log.Info("Checking if kagent-tools service is accessible", "namespace", namespace) + output, err := commands.NewCommandBuilder("kubectl"). + WithArgs("get", "svc", "-n", namespace, "-l", "app.kubernetes.io/instance="+releaseName, "-o", "json"). + Execute(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(output).ToNot(BeEmpty()) + log.Info("Successfully verified kagent-tools service", "namespace", namespace, "output", output) + }) + }) + + Describe("KAgent Tools K8s Operations", func() { + It("should be able to list namespace in the cluster", func() { + log.Info("Testing MCP client connectivity and k8s operations", "namespace", namespace) + + // Test k8s list resources functionality + log.Info("Testing k8s list resources via MCP") + response, err := client.k8sListResources("namespace") + Expect(err).ToNot(HaveOccurred(), "Failed to list k8s resources via MCP: %v", err) + Expect(response).ToNot(BeNil()) + + log.Info("Successfully tested k8s operations via MCP", "namespace", namespace) + }) + }) + + Describe("KAgent Tools Helm Operations", func() { + It("should be able to list all helm releases", func() { + log.Info("Testing helm operations via MCP", "namespace", namespace) + + // Test helm list releases functionality + log.Info("Testing helm list releases via MCP") + response, err := client.helmListReleases() + if err != nil { + log.Info("Helm list releases failed (may be normal)", "error", err) + Skip(fmt.Sprintf("Helm operations not available: %v", err)) + return + } + Expect(response).ToNot(BeNil()) + log.Info("Successfully tested helm operations via MCP", "namespace", namespace) + }) + }) + + Describe("KAgent Tools Istio Operations", func() { + It("should be able to install istio in the cluster", func() { + log.Info("Testing istio operations via MCP", "namespace", namespace) + + // If we get here, MCP is accessible, test istio operations + response, err := client.istioInstall("default") + Expect(err).ToNot(HaveOccurred(), "Failed to install istio via MCP: %v", err) + Expect(response).ToNot(BeNil()) + + log.Info("Successfully tested istio operations via MCP", "namespace", namespace, "response", response) + }) + }) + + Describe("KAgent Tools Cilium Operations", func() { + It("should be able to install cilium in the cluster", func() { + log.Info("Testing cilium operations via MCP", "namespace", namespace) + + // If we get here, MCP is accessible, test cilium operations + response, err := client.ciliumStatus() + Expect(err).ToNot(HaveOccurred(), "Failed to get cilium status via MCP: %v", err) + Expect(response).ToNot(BeNil()) + + log.Info("Successfully tested cilium operations via MCP", "namespace", namespace) + }) + }) + + Describe("KAgent Tools Argo Operations", func() { + It("should be able to list Argo rollouts in the cluster", func() { + log.Info("Testing Argo operations via MCP", "namespace", namespace) + + // If we get here, MCP is accessible, test cilium operations + response, err := client.argoRolloutsList(namespace) + Expect(err).ToNot(HaveOccurred(), "Failed to list argo rollouts via MCP: %v", err) + Expect(response).ToNot(BeNil()) + + log.Info("Successfully tested argo rollouts via MCP", "namespace", namespace) + }) + }) +})