@build-astron-co/nimbus 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nimbus +26 -10
- package/bin/nimbus.cmd +41 -0
- package/bin/nimbus.mjs +70 -0
- package/completions/nimbus.bash +38 -0
- package/completions/nimbus.fish +48 -0
- package/completions/nimbus.zsh +81 -0
- package/dist/src/agent/compaction-agent.js +215 -0
- package/dist/src/agent/context-manager.js +385 -0
- package/dist/src/agent/context.js +322 -0
- package/dist/src/agent/deploy-preview.js +395 -0
- package/dist/src/agent/expand-files.js +95 -0
- package/dist/src/agent/index.js +18 -0
- package/dist/src/agent/loop.js +1535 -0
- package/dist/src/agent/modes.js +347 -0
- package/dist/src/agent/permissions.js +396 -0
- package/dist/src/agent/subagents/base.js +67 -0
- package/dist/src/agent/subagents/cost.js +45 -0
- package/dist/src/agent/subagents/explore.js +36 -0
- package/dist/src/agent/subagents/general.js +41 -0
- package/dist/src/agent/subagents/index.js +88 -0
- package/dist/src/agent/subagents/infra.js +52 -0
- package/dist/src/agent/subagents/security.js +60 -0
- package/dist/src/agent/system-prompt.js +860 -0
- package/dist/src/app.js +152 -0
- package/dist/src/audit/activity-log.js +209 -0
- package/dist/src/audit/compliance-checker.js +419 -0
- package/dist/src/audit/cost-tracker.js +231 -0
- package/dist/src/audit/index.js +10 -0
- package/dist/src/audit/security-scanner.js +490 -0
- package/dist/src/auth/guard.js +64 -0
- package/dist/src/auth/index.js +19 -0
- package/dist/src/auth/keychain.js +79 -0
- package/dist/src/auth/oauth.js +389 -0
- package/dist/src/auth/providers.js +415 -0
- package/dist/src/auth/sso.js +87 -0
- package/dist/src/auth/store.js +424 -0
- package/dist/src/auth/types.js +5 -0
- package/dist/src/cli/index.js +8 -0
- package/dist/src/cli/init.js +1048 -0
- package/dist/src/cli/openapi-spec.js +346 -0
- package/dist/src/cli/run.js +505 -0
- package/dist/src/cli/serve-auth.js +56 -0
- package/dist/src/cli/serve.js +432 -0
- package/dist/src/cli/web.js +50 -0
- package/dist/src/cli.js +1574 -0
- package/dist/src/clients/core-engine-client.js +156 -0
- package/dist/src/clients/enterprise-client.js +246 -0
- package/dist/src/clients/generator-client.js +219 -0
- package/dist/src/clients/git-client.js +367 -0
- package/dist/src/clients/github-client.js +229 -0
- package/dist/src/clients/helm-client.js +299 -0
- package/dist/src/clients/index.js +18 -0
- package/dist/src/clients/k8s-client.js +270 -0
- package/dist/src/clients/llm-client.js +119 -0
- package/dist/src/clients/rest-client.js +104 -0
- package/dist/src/clients/service-discovery.js +35 -0
- package/dist/src/clients/terraform-client.js +302 -0
- package/dist/src/clients/tools-client.js +1227 -0
- package/dist/src/clients/ws-client.js +93 -0
- package/dist/src/commands/alias.js +91 -0
- package/dist/src/commands/analyze/index.js +313 -0
- package/dist/src/commands/apply/helm.js +375 -0
- package/dist/src/commands/apply/index.js +176 -0
- package/dist/src/commands/apply/k8s.js +350 -0
- package/dist/src/commands/apply/terraform.js +465 -0
- package/dist/src/commands/ask.js +137 -0
- package/dist/src/commands/audit/index.js +322 -0
- package/dist/src/commands/auth-cloud.js +345 -0
- package/dist/src/commands/auth-list.js +112 -0
- package/dist/src/commands/auth-profile.js +104 -0
- package/dist/src/commands/auth-refresh.js +161 -0
- package/dist/src/commands/auth-status.js +122 -0
- package/dist/src/commands/aws/ec2.js +402 -0
- package/dist/src/commands/aws/iam.js +304 -0
- package/dist/src/commands/aws/index.js +108 -0
- package/dist/src/commands/aws/lambda.js +317 -0
- package/dist/src/commands/aws/rds.js +345 -0
- package/dist/src/commands/aws/s3.js +346 -0
- package/dist/src/commands/aws/vpc.js +302 -0
- package/dist/src/commands/aws-discover.js +413 -0
- package/dist/src/commands/aws-terraform.js +618 -0
- package/dist/src/commands/azure/aks.js +305 -0
- package/dist/src/commands/azure/functions.js +200 -0
- package/dist/src/commands/azure/index.js +93 -0
- package/dist/src/commands/azure/storage.js +378 -0
- package/dist/src/commands/azure/vm.js +291 -0
- package/dist/src/commands/billing/index.js +224 -0
- package/dist/src/commands/chat.js +259 -0
- package/dist/src/commands/completions.js +255 -0
- package/dist/src/commands/config.js +291 -0
- package/dist/src/commands/cost/cloud-cost-estimator.js +211 -0
- package/dist/src/commands/cost/estimator.js +73 -0
- package/dist/src/commands/cost/index.js +625 -0
- package/dist/src/commands/cost/parsers/terraform.js +234 -0
- package/dist/src/commands/cost/parsers/types.js +4 -0
- package/dist/src/commands/cost/pricing/aws.js +501 -0
- package/dist/src/commands/cost/pricing/azure.js +462 -0
- package/dist/src/commands/cost/pricing/gcp.js +359 -0
- package/dist/src/commands/cost/pricing/index.js +24 -0
- package/dist/src/commands/demo.js +196 -0
- package/dist/src/commands/deploy.js +215 -0
- package/dist/src/commands/doctor.js +1291 -0
- package/dist/src/commands/drift/index.js +674 -0
- package/dist/src/commands/explain.js +235 -0
- package/dist/src/commands/export.js +120 -0
- package/dist/src/commands/feedback.js +319 -0
- package/dist/src/commands/fix.js +263 -0
- package/dist/src/commands/fs/index.js +338 -0
- package/dist/src/commands/gcp/compute.js +266 -0
- package/dist/src/commands/gcp/functions.js +221 -0
- package/dist/src/commands/gcp/gke.js +357 -0
- package/dist/src/commands/gcp/iam.js +295 -0
- package/dist/src/commands/gcp/index.js +105 -0
- package/dist/src/commands/gcp/storage.js +232 -0
- package/dist/src/commands/generate-helm.js +1026 -0
- package/dist/src/commands/generate-k8s.js +1263 -0
- package/dist/src/commands/generate-terraform.js +1058 -0
- package/dist/src/commands/gh/index.js +663 -0
- package/dist/src/commands/git/index.js +1208 -0
- package/dist/src/commands/helm/index.js +985 -0
- package/dist/src/commands/help.js +639 -0
- package/dist/src/commands/history.js +120 -0
- package/dist/src/commands/import.js +782 -0
- package/dist/src/commands/incident.js +144 -0
- package/dist/src/commands/index.js +109 -0
- package/dist/src/commands/init.js +955 -0
- package/dist/src/commands/k8s/index.js +979 -0
- package/dist/src/commands/login.js +588 -0
- package/dist/src/commands/logout.js +61 -0
- package/dist/src/commands/logs.js +160 -0
- package/dist/src/commands/onboarding.js +382 -0
- package/dist/src/commands/pipeline.js +153 -0
- package/dist/src/commands/plan/display.js +216 -0
- package/dist/src/commands/plan/index.js +525 -0
- package/dist/src/commands/plugin.js +325 -0
- package/dist/src/commands/preview.js +356 -0
- package/dist/src/commands/profile.js +297 -0
- package/dist/src/commands/questionnaire.js +1021 -0
- package/dist/src/commands/resume.js +35 -0
- package/dist/src/commands/rollback.js +259 -0
- package/dist/src/commands/rollout.js +74 -0
- package/dist/src/commands/runbook.js +307 -0
- package/dist/src/commands/schedule.js +202 -0
- package/dist/src/commands/status.js +213 -0
- package/dist/src/commands/team/index.js +309 -0
- package/dist/src/commands/team-context.js +200 -0
- package/dist/src/commands/template.js +204 -0
- package/dist/src/commands/tf/index.js +989 -0
- package/dist/src/commands/upgrade.js +515 -0
- package/dist/src/commands/usage/index.js +118 -0
- package/dist/src/commands/version.js +145 -0
- package/dist/src/commands/watch.js +127 -0
- package/dist/src/compat/index.js +2 -0
- package/dist/src/compat/runtime.js +10 -0
- package/dist/src/compat/sqlite.js +144 -0
- package/dist/src/config/index.js +6 -0
- package/dist/src/config/manager.js +469 -0
- package/dist/src/config/mode-store.js +57 -0
- package/dist/src/config/profiles.js +66 -0
- package/dist/src/config/safety-policy.js +251 -0
- package/dist/src/config/schema.js +107 -0
- package/dist/src/config/types.js +311 -0
- package/dist/src/config/workspace-state.js +38 -0
- package/dist/src/context/context-db.js +138 -0
- package/dist/src/demo/index.js +295 -0
- package/dist/src/demo/scenarios/full-journey.js +226 -0
- package/dist/src/demo/scenarios/getting-started.js +124 -0
- package/dist/src/demo/scenarios/helm-release.js +334 -0
- package/dist/src/demo/scenarios/k8s-deployment.js +190 -0
- package/dist/src/demo/scenarios/terraform-vpc.js +167 -0
- package/dist/src/demo/types.js +6 -0
- package/dist/src/engine/cost-estimator.js +334 -0
- package/dist/src/engine/diagram-generator.js +192 -0
- package/dist/src/engine/drift-detector.js +688 -0
- package/dist/src/engine/executor.js +832 -0
- package/dist/src/engine/index.js +39 -0
- package/dist/src/engine/orchestrator.js +436 -0
- package/dist/src/engine/planner.js +616 -0
- package/dist/src/engine/safety.js +609 -0
- package/dist/src/engine/verifier.js +664 -0
- package/dist/src/enterprise/audit.js +241 -0
- package/dist/src/enterprise/auth.js +189 -0
- package/dist/src/enterprise/billing.js +512 -0
- package/dist/src/enterprise/index.js +16 -0
- package/dist/src/enterprise/teams.js +315 -0
- package/dist/src/generator/best-practices.js +1375 -0
- package/dist/src/generator/helm.js +495 -0
- package/dist/src/generator/index.js +11 -0
- package/dist/src/generator/intent-parser.js +420 -0
- package/dist/src/generator/kubernetes.js +773 -0
- package/dist/src/generator/terraform.js +1472 -0
- package/dist/src/history/index.js +6 -0
- package/dist/src/history/manager.js +199 -0
- package/dist/src/history/types.js +6 -0
- package/dist/src/hooks/config.js +318 -0
- package/dist/src/hooks/engine.js +317 -0
- package/dist/src/hooks/index.js +2 -0
- package/dist/src/llm/auth-bridge.js +157 -0
- package/dist/src/llm/circuit-breaker.js +116 -0
- package/dist/src/llm/config-loader.js +172 -0
- package/dist/src/llm/cost-calculator.js +137 -0
- package/dist/src/llm/index.js +7 -0
- package/dist/src/llm/model-aliases.js +99 -0
- package/dist/src/llm/provider-registry.js +57 -0
- package/dist/src/llm/providers/anthropic.js +430 -0
- package/dist/src/llm/providers/bedrock.js +409 -0
- package/dist/src/llm/providers/google.js +344 -0
- package/dist/src/llm/providers/ollama.js +661 -0
- package/dist/src/llm/providers/openai-compatible.js +289 -0
- package/dist/src/llm/providers/openai.js +284 -0
- package/dist/src/llm/providers/openrouter.js +293 -0
- package/dist/src/llm/router.js +844 -0
- package/dist/src/llm/types.js +69 -0
- package/dist/src/lsp/client.js +239 -0
- package/dist/src/lsp/languages.js +95 -0
- package/dist/src/lsp/manager.js +243 -0
- package/dist/src/mcp/client.js +289 -0
- package/dist/src/mcp/index.js +5 -0
- package/dist/src/mcp/manager.js +113 -0
- package/dist/src/nimbus.js +212 -0
- package/dist/src/plugins/index.js +13 -0
- package/dist/src/plugins/loader.js +280 -0
- package/dist/src/plugins/manager.js +282 -0
- package/dist/src/plugins/types.js +23 -0
- package/dist/src/scanners/cicd-scanner.js +230 -0
- package/dist/src/scanners/cloud-scanner.js +415 -0
- package/dist/src/scanners/framework-scanner.js +430 -0
- package/dist/src/scanners/iac-scanner.js +350 -0
- package/dist/src/scanners/index.js +454 -0
- package/dist/src/scanners/language-scanner.js +258 -0
- package/dist/src/scanners/package-manager-scanner.js +252 -0
- package/dist/src/scanners/types.js +6 -0
- package/dist/src/sessions/manager.js +395 -0
- package/dist/src/sessions/types.js +4 -0
- package/dist/src/sharing/sync.js +238 -0
- package/dist/src/sharing/viewer.js +131 -0
- package/dist/src/snapshots/index.js +1 -0
- package/dist/src/snapshots/manager.js +432 -0
- package/dist/src/state/artifacts.js +94 -0
- package/dist/src/state/audit.js +73 -0
- package/dist/src/state/billing.js +126 -0
- package/dist/src/state/checkpoints.js +81 -0
- package/dist/src/state/config.js +58 -0
- package/dist/src/state/conversations.js +7 -0
- package/dist/src/state/credentials.js +96 -0
- package/dist/src/state/db.js +53 -0
- package/dist/src/state/index.js +23 -0
- package/dist/src/state/messages.js +76 -0
- package/dist/src/state/projects.js +92 -0
- package/dist/src/state/schema.js +233 -0
- package/dist/src/state/sessions.js +79 -0
- package/dist/src/state/teams.js +131 -0
- package/dist/src/telemetry.js +91 -0
- package/dist/src/tools/aws-ops.js +747 -0
- package/dist/src/tools/azure-ops.js +491 -0
- package/dist/src/tools/file-ops.js +451 -0
- package/dist/src/tools/gcp-ops.js +559 -0
- package/dist/src/tools/git-ops.js +557 -0
- package/dist/src/tools/github-ops.js +460 -0
- package/dist/src/tools/helm-ops.js +634 -0
- package/dist/src/tools/index.js +16 -0
- package/dist/src/tools/k8s-ops.js +579 -0
- package/dist/src/tools/schemas/converter.js +129 -0
- package/dist/src/tools/schemas/devops.js +3319 -0
- package/dist/src/tools/schemas/index.js +19 -0
- package/dist/src/tools/schemas/standard.js +966 -0
- package/dist/src/tools/schemas/types.js +409 -0
- package/dist/src/tools/spawn-exec.js +109 -0
- package/dist/src/tools/terraform-ops.js +627 -0
- package/dist/src/types/config.js +1 -0
- package/dist/src/types/drift.js +4 -0
- package/dist/src/types/enterprise.js +5 -0
- package/dist/src/types/index.js +14 -0
- package/dist/src/types/plan.js +1 -0
- package/dist/src/types/request.js +1 -0
- package/dist/src/types/response.js +1 -0
- package/dist/src/types/service.js +1 -0
- package/dist/src/ui/App.js +1672 -0
- package/dist/src/ui/DeployPreview.js +60 -0
- package/dist/src/ui/FileDiffModal.js +108 -0
- package/dist/src/ui/Header.js +46 -0
- package/dist/src/ui/HelpModal.js +9 -0
- package/dist/src/ui/InputBox.js +408 -0
- package/dist/src/ui/MessageList.js +795 -0
- package/dist/src/ui/PermissionPrompt.js +72 -0
- package/dist/src/ui/StatusBar.js +109 -0
- package/dist/src/ui/TerminalPane.js +31 -0
- package/dist/src/ui/ToolCallDisplay.js +303 -0
- package/dist/src/ui/TreePane.js +83 -0
- package/dist/src/ui/chat-ui.js +721 -0
- package/dist/src/ui/index.js +11 -0
- package/dist/src/ui/ink/index.js +1325 -0
- package/dist/src/ui/streaming.js +137 -0
- package/dist/src/ui/theme.js +78 -0
- package/dist/src/ui/types.js +7 -0
- package/dist/src/utils/analytics.js +61 -0
- package/dist/src/utils/cost-warning.js +25 -0
- package/dist/src/utils/env.js +42 -0
- package/dist/src/utils/errors.js +54 -0
- package/dist/src/utils/event-bus.js +22 -0
- package/dist/src/utils/index.js +16 -0
- package/dist/src/utils/logger.js +150 -0
- package/dist/src/utils/rate-limiter.js +90 -0
- package/dist/src/utils/service-auth.js +36 -0
- package/dist/src/utils/validation.js +39 -0
- package/dist/src/version.js +3 -0
- package/dist/src/watcher/index.js +192 -0
- package/dist/src/wizard/approval.js +275 -0
- package/dist/src/wizard/index.js +13 -0
- package/dist/src/wizard/prompts.js +273 -0
- package/dist/src/wizard/types.js +4 -0
- package/dist/src/wizard/ui.js +453 -0
- package/dist/src/wizard/wizard.js +227 -0
- package/package.json +31 -23
- package/src/__tests__/alias.test.ts +133 -0
- package/src/__tests__/app.test.ts +1 -1
- package/src/__tests__/audit.test.ts +1 -1
- package/src/__tests__/circuit-breaker.test.ts +1 -1
- package/src/__tests__/cli-run.test.ts +237 -1
- package/src/__tests__/compat-sqlite.test.ts +68 -0
- package/src/__tests__/context-manager.test.ts +131 -1
- package/src/__tests__/context.test.ts +1 -1
- package/src/__tests__/devops-terminal-gaps.test.ts +718 -0
- package/src/__tests__/doctor.test.ts +48 -0
- package/src/__tests__/enterprise.test.ts +1 -1
- package/src/__tests__/export.test.ts +236 -0
- package/src/__tests__/gap-11-18-20.test.ts +958 -0
- package/src/__tests__/generator.test.ts +1 -1
- package/src/__tests__/helm-streaming.test.ts +127 -0
- package/src/__tests__/hooks.test.ts +1 -1
- package/src/__tests__/incident.test.ts +179 -0
- package/src/__tests__/init.test.ts +55 -4
- package/src/__tests__/intent-parser.test.ts +1 -1
- package/src/__tests__/llm-router.test.ts +1 -1
- package/src/__tests__/logs.test.ts +107 -0
- package/src/__tests__/loop-errors.test.ts +244 -0
- package/src/__tests__/lsp.test.ts +1 -1
- package/src/__tests__/modes.test.ts +1 -1
- package/src/__tests__/perf-optimizations.test.ts +847 -0
- package/src/__tests__/permissions.test.ts +1 -1
- package/src/__tests__/pipeline.test.ts +50 -0
- package/src/__tests__/polish-phase3.test.ts +340 -0
- package/src/__tests__/profile.test.ts +237 -0
- package/src/__tests__/rollback.test.ts +83 -0
- package/src/__tests__/runbook.test.ts +219 -0
- package/src/__tests__/schedule.test.ts +206 -0
- package/src/__tests__/serve.test.ts +1 -1
- package/src/__tests__/sessions.test.ts +96 -1
- package/src/__tests__/sharing.test.ts +53 -1
- package/src/__tests__/snapshots.test.ts +1 -1
- package/src/__tests__/standalone-migration.test.ts +199 -0
- package/src/__tests__/state-db.test.ts +1 -1
- package/src/__tests__/status.test.ts +158 -0
- package/src/__tests__/stream-with-tools.test.ts +71 -25
- package/src/__tests__/subagents.test.ts +1 -1
- package/src/__tests__/system-prompt.test.ts +82 -3
- package/src/__tests__/terminal-gap-v2.test.ts +395 -0
- package/src/__tests__/terminal-parity.test.ts +393 -0
- package/src/__tests__/tf-apply.test.ts +187 -0
- package/src/__tests__/tool-converter.test.ts +1 -1
- package/src/__tests__/tool-schemas.test.ts +209 -4
- package/src/__tests__/tools.test.ts +4 -3
- package/src/__tests__/version-json.test.ts +184 -0
- package/src/__tests__/version.test.ts +1 -1
- package/src/__tests__/watch.test.ts +129 -0
- package/src/agent/compaction-agent.ts +40 -1
- package/src/agent/context-manager.ts +67 -3
- package/src/agent/deploy-preview.ts +62 -1
- package/src/agent/expand-files.ts +108 -0
- package/src/agent/loop.ts +1312 -31
- package/src/agent/permissions.ts +51 -4
- package/src/agent/system-prompt.ts +573 -19
- package/src/app.ts +58 -0
- package/src/audit/security-scanner.ts +45 -0
- package/src/auth/keychain.ts +82 -0
- package/src/auth/oauth.ts +15 -5
- package/src/cli/init.ts +378 -5
- package/src/cli/run.ts +407 -16
- package/src/cli/serve.ts +78 -1
- package/src/cli/web.ts +10 -6
- package/src/cli.ts +312 -1
- package/src/clients/service-discovery.ts +30 -25
- package/src/commands/alias.ts +100 -0
- package/src/commands/audit/index.ts +121 -2
- package/src/commands/auth-cloud.ts +113 -0
- package/src/commands/auth-refresh.ts +187 -0
- package/src/commands/aws-discover.ts +144 -251
- package/src/commands/aws-terraform.ts +68 -118
- package/src/commands/chat.ts +9 -3
- package/src/commands/completions.ts +268 -0
- package/src/commands/config.ts +26 -0
- package/src/commands/cost/index.ts +218 -2
- package/src/commands/deploy.ts +260 -0
- package/src/commands/doctor.ts +744 -152
- package/src/commands/drift/index.ts +371 -23
- package/src/commands/export.ts +146 -0
- package/src/commands/generate-k8s.ts +9 -61
- package/src/commands/generate-terraform.ts +191 -449
- package/src/commands/help.ts +212 -36
- package/src/commands/history.ts +8 -1
- package/src/commands/incident.ts +166 -0
- package/src/commands/init.ts +5 -0
- package/src/commands/login.ts +86 -1
- package/src/commands/logs.ts +167 -0
- package/src/commands/onboarding.ts +211 -34
- package/src/commands/pipeline.ts +186 -0
- package/src/commands/plugin.ts +398 -0
- package/src/commands/profile.ts +342 -0
- package/src/commands/questionnaire.ts +0 -98
- package/src/commands/resume.ts +26 -34
- package/src/commands/rollback.ts +315 -0
- package/src/commands/rollout.ts +88 -0
- package/src/commands/runbook.ts +346 -0
- package/src/commands/schedule.ts +236 -0
- package/src/commands/status.ts +252 -0
- package/src/commands/team-context.ts +220 -0
- package/src/commands/template.ts +58 -57
- package/src/commands/tf/index.ts +70 -11
- package/src/commands/upgrade.ts +57 -0
- package/src/commands/version.ts +54 -50
- package/src/commands/watch.ts +153 -0
- package/src/compat/runtime.ts +1 -1
- package/src/compat/sqlite.ts +75 -5
- package/src/config/mode-store.ts +62 -0
- package/src/config/profiles.ts +84 -0
- package/src/config/types.ts +83 -1
- package/src/config/workspace-state.ts +53 -0
- package/src/engine/cost-estimator.ts +52 -10
- package/src/engine/executor.ts +33 -2
- package/src/engine/planner.ts +68 -1
- package/src/generator/terraform.ts +8 -0
- package/src/history/manager.ts +2 -74
- package/src/hooks/engine.ts +5 -4
- package/src/llm/cost-calculator.ts +2 -2
- package/src/llm/providers/anthropic.ts +50 -21
- package/src/llm/router.ts +76 -7
- package/src/lsp/languages.ts +3 -0
- package/src/lsp/manager.ts +21 -5
- package/src/nimbus.ts +37 -18
- package/src/sessions/manager.ts +108 -1
- package/src/sharing/sync.ts +4 -0
- package/src/sharing/viewer.ts +66 -0
- package/src/tools/file-ops.ts +22 -0
- package/src/tools/schemas/devops.ts +3007 -117
- package/src/tools/schemas/standard.ts +5 -1
- package/src/tools/schemas/types.ts +31 -1
- package/src/tools/spawn-exec.ts +148 -0
- package/src/ui/App.tsx +1183 -66
- package/src/ui/DeployPreview.tsx +62 -57
- package/src/ui/FileDiffModal.tsx +162 -0
- package/src/ui/Header.tsx +87 -24
- package/src/ui/HelpModal.tsx +57 -0
- package/src/ui/InputBox.tsx +163 -10
- package/src/ui/MessageList.tsx +487 -40
- package/src/ui/PermissionPrompt.tsx +17 -5
- package/src/ui/StatusBar.tsx +122 -3
- package/src/ui/TerminalPane.tsx +84 -0
- package/src/ui/ToolCallDisplay.tsx +252 -18
- package/src/ui/TreePane.tsx +132 -0
- package/src/ui/chat-ui.ts +41 -44
- package/src/ui/ink/index.ts +771 -38
- package/src/ui/streaming.ts +1 -1
- package/src/ui/theme.ts +104 -0
- package/src/ui/types.ts +18 -0
- package/src/version.ts +1 -1
- package/src/watcher/index.ts +66 -15
- package/src/wizard/types.ts +1 -0
- package/src/wizard/ui.ts +1 -1
- package/tsconfig.json +2 -2
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DevOps Tool Definitions
|
|
3
3
|
*
|
|
4
|
-
* Defines the
|
|
4
|
+
* Defines the 12 DevOps-specific tools available to the Nimbus agentic loop.
|
|
5
5
|
* Each tool wraps existing infrastructure operations from `src/tools/` modules
|
|
6
6
|
* or invokes the appropriate CLI via child_process.
|
|
7
7
|
*
|
|
8
8
|
* Tools:
|
|
9
9
|
* terraform, kubectl, helm, cloud_discover, cost_estimate,
|
|
10
|
-
* drift_detect, deploy_preview,
|
|
10
|
+
* drift_detect, deploy_preview, terraform_plan_analyze,
|
|
11
|
+
* kubectl_context, helm_values, git, task
|
|
11
12
|
*
|
|
12
13
|
* @module tools/schemas/devops
|
|
13
14
|
*/
|
|
@@ -15,10 +16,19 @@
|
|
|
15
16
|
import { z } from 'zod';
|
|
16
17
|
import { exec } from 'node:child_process';
|
|
17
18
|
import { promisify } from 'node:util';
|
|
19
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
20
|
+
import { join as pathJoin } from 'node:path';
|
|
18
21
|
import type { ToolDefinition, ToolResult } from './types';
|
|
22
|
+
import { spawnExec } from '../spawn-exec';
|
|
19
23
|
|
|
20
24
|
const execAsync = promisify(exec);
|
|
21
25
|
|
|
26
|
+
/** GAP-20: Default timeout for spawnExec calls (10 minutes). */
|
|
27
|
+
const DEFAULT_TIMEOUT = 600_000;
|
|
28
|
+
|
|
29
|
+
/** GAP-26: Map from cwd → plan file path, for terraform plan → apply workflow */
|
|
30
|
+
const terraformPlanFiles = new Map<string, string>();
|
|
31
|
+
|
|
22
32
|
// ---------------------------------------------------------------------------
|
|
23
33
|
// Helpers
|
|
24
34
|
// ---------------------------------------------------------------------------
|
|
@@ -51,61 +61,294 @@ function errorMessage(error: unknown): string {
|
|
|
51
61
|
return error instanceof Error ? error.message : String(error);
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// H6: Output formatting helpers
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Format `kubectl get pods` tabular output with status emoji indicators.
|
|
70
|
+
* Prefixes each pod row with [OK] (Running), [!!](Pending/Init), [XX] (Error/CrashLoop).
|
|
71
|
+
*/
|
|
72
|
+
export function formatKubectlPodsOutput(raw: string): string {
|
|
73
|
+
const lines = raw.split('\n');
|
|
74
|
+
const result: string[] = [];
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
if (!line.trim() || line.startsWith('NAME')) {
|
|
77
|
+
result.push(line);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const cols = line.trim().split(/\s+/);
|
|
81
|
+
// Status is typically the 3rd column in `kubectl get pods` output
|
|
82
|
+
const status = cols[2] ?? '';
|
|
83
|
+
let emoji: string;
|
|
84
|
+
if (/Running/i.test(status)) {
|
|
85
|
+
emoji = '[OK]';
|
|
86
|
+
} else if (/Pending|Init:|ContainerCreating|PodInitializing/i.test(status)) {
|
|
87
|
+
emoji = '[!!]';
|
|
88
|
+
} else if (/Error|CrashLoop|OOMKilled|Evicted|Failed|ImagePullBackOff|ErrImagePull/i.test(status)) {
|
|
89
|
+
emoji = '[XX]';
|
|
90
|
+
} else if (/Completed|Succeeded/i.test(status)) {
|
|
91
|
+
emoji = '[OK]';
|
|
92
|
+
} else if (/Terminating/i.test(status)) {
|
|
93
|
+
emoji = '[!!]';
|
|
94
|
+
} else {
|
|
95
|
+
emoji = ' ';
|
|
96
|
+
}
|
|
97
|
+
result.push(`${emoji} ${line}`);
|
|
98
|
+
}
|
|
99
|
+
return result.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format `helm list -o json` output into a human-readable list with ASCII status icons.
|
|
104
|
+
*/
|
|
105
|
+
export function formatHelmListOutput(raw: string): string {
|
|
106
|
+
try {
|
|
107
|
+
const releases = JSON.parse(raw) as Array<{
|
|
108
|
+
name: string;
|
|
109
|
+
namespace: string;
|
|
110
|
+
revision: string;
|
|
111
|
+
status: string;
|
|
112
|
+
chart: string;
|
|
113
|
+
app_version: string;
|
|
114
|
+
updated: string;
|
|
115
|
+
}>;
|
|
116
|
+
if (!Array.isArray(releases) || releases.length === 0) return 'No Helm releases found.';
|
|
117
|
+
const lines = releases.map(r => {
|
|
118
|
+
let emoji: string;
|
|
119
|
+
const s = r.status?.toLowerCase() ?? '';
|
|
120
|
+
if (s === 'deployed') emoji = '[OK]';
|
|
121
|
+
else if (s === 'pending-install' || s === 'pending-upgrade') emoji = '[!!]';
|
|
122
|
+
else if (s === 'failed') emoji = '[XX]';
|
|
123
|
+
else if (s === 'superseded') emoji = '[~~]';
|
|
124
|
+
else emoji = ' ';
|
|
125
|
+
return `${emoji} ${r.name} (${r.namespace}) — ${r.chart} rev.${r.revision} [${r.status}]`;
|
|
126
|
+
});
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
} catch {
|
|
129
|
+
return raw;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if a Terraform workdir uses a remote backend (cloud {} or backend "remote").
|
|
135
|
+
* If so, returns a warning message; otherwise null.
|
|
136
|
+
*/
|
|
137
|
+
async function checkRemoteBackend(workdir: string): Promise<string | null> {
|
|
138
|
+
try {
|
|
139
|
+
const { readdir, readFile } = await import('node:fs/promises');
|
|
140
|
+
const { join: joinPath } = await import('node:path');
|
|
141
|
+
const entries = await readdir(workdir);
|
|
142
|
+
const tfFiles = entries.filter(f => f.endsWith('.tf'));
|
|
143
|
+
for (const file of tfFiles) {
|
|
144
|
+
const fileContent = await readFile(joinPath(workdir, file), 'utf-8');
|
|
145
|
+
if (/^\s*(cloud|backend\s+"remote")\s*\{/m.test(fileContent)) {
|
|
146
|
+
return 'Remote backend detected — this operation affects shared state. Ensure you have the correct permissions and workspace selected.';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch { /* ignore FS errors */ }
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
54
153
|
// ---------------------------------------------------------------------------
|
|
55
154
|
// 1. terraform
|
|
56
155
|
// ---------------------------------------------------------------------------
|
|
57
156
|
|
|
58
157
|
const terraformSchema = z.object({
|
|
59
158
|
action: z
|
|
60
|
-
.enum([
|
|
159
|
+
.enum([
|
|
160
|
+
'init', 'plan', 'apply', 'validate', 'fmt', 'destroy', 'import',
|
|
161
|
+
'state', 'state-list', 'state-show', 'state-rm', 'state-mv',
|
|
162
|
+
'output', 'workspace-list', 'workspace-select', 'workspace-new',
|
|
163
|
+
'providers', 'graph', 'force-unlock',
|
|
164
|
+
])
|
|
61
165
|
.describe('The Terraform sub-command to run'),
|
|
62
166
|
workdir: z.string().describe('Working directory containing the Terraform configuration'),
|
|
63
167
|
args: z.string().optional().describe('Additional CLI arguments'),
|
|
64
168
|
var_file: z.string().optional().describe('Path to a .tfvars variable file'),
|
|
169
|
+
state_address: z.string().optional().describe('Resource address for state operations (e.g., "aws_instance.example")'),
|
|
170
|
+
workspace: z.string().optional().describe('Workspace name for workspace-select/workspace-new'),
|
|
171
|
+
output_name: z.string().optional().describe('Output name for terraform output (omit for all outputs)'),
|
|
172
|
+
lock_id: z.string().optional().describe('Lock ID for force-unlock'),
|
|
173
|
+
env: z.record(z.string(), z.string()).optional().describe('Extra environment variables (e.g., AWS_PROFILE, TF_WORKSPACE)'),
|
|
65
174
|
});
|
|
66
175
|
|
|
67
176
|
export const terraformTool: ToolDefinition = {
|
|
68
177
|
name: 'terraform',
|
|
69
178
|
description:
|
|
70
|
-
'Execute Terraform operations. Supports init, plan, apply, validate, fmt, destroy, import, and
|
|
179
|
+
'Execute Terraform operations. Supports init, plan, apply, validate, fmt, destroy, import, state, output, workspace, providers, graph, and force-unlock commands.',
|
|
71
180
|
inputSchema: terraformSchema,
|
|
72
181
|
permissionTier: 'always_ask',
|
|
73
182
|
category: 'devops',
|
|
74
183
|
isDestructive: true,
|
|
75
184
|
|
|
76
|
-
async execute(raw: unknown): Promise<ToolResult> {
|
|
185
|
+
async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
|
|
77
186
|
try {
|
|
78
187
|
const input = terraformSchema.parse(raw);
|
|
79
188
|
|
|
80
|
-
|
|
189
|
+
// C2: If no workspace specified but session has a workspace context, note it in output
|
|
190
|
+
const sessionWorkspace = ctx?.infraContext?.terraformWorkspace;
|
|
81
191
|
|
|
82
|
-
|
|
83
|
-
|
|
192
|
+
// For apply: run validate → plan first to catch errors early
|
|
193
|
+
if (input.action === 'apply') {
|
|
194
|
+
// Step 1: validate
|
|
195
|
+
try {
|
|
196
|
+
const { stdout: valOut, stderr: valErr } = await execAsync(
|
|
197
|
+
`terraform -chdir=${input.workdir} validate -no-color`,
|
|
198
|
+
{ timeout: 60_000, maxBuffer: 2 * 1024 * 1024 }
|
|
199
|
+
);
|
|
200
|
+
const valCombined = [valOut, valErr].filter(Boolean).join('\n');
|
|
201
|
+
if (valCombined.includes('Error:')) {
|
|
202
|
+
return err(`Terraform validate failed — fix errors before applying:\n${valCombined}`);
|
|
203
|
+
}
|
|
204
|
+
} catch (valErr: unknown) {
|
|
205
|
+
return err(`Terraform validate failed:\n${errorMessage(valErr)}`);
|
|
206
|
+
}
|
|
84
207
|
}
|
|
85
208
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
209
|
+
// For destroy: require explicit confirmation keyword in args to prevent accidents
|
|
210
|
+
if (input.action === 'destroy') {
|
|
211
|
+
const prodIndicators = ['prod', 'production', 'prd', 'live'];
|
|
212
|
+
const workdirLower = input.workdir.toLowerCase();
|
|
213
|
+
const isProd = prodIndicators.some(p => workdirLower.includes(p));
|
|
214
|
+
if (isProd && !input.args?.includes('--confirmed-destroy')) {
|
|
215
|
+
return err(
|
|
216
|
+
`SAFETY CHECK: Production environment detected in workdir "${input.workdir}".\n` +
|
|
217
|
+
`To proceed with destroy, add "--confirmed-destroy" to args.\n` +
|
|
218
|
+
`This is a safety guard against accidental production teardowns.`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
90
221
|
}
|
|
91
222
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
parts.push('-no-color');
|
|
95
|
-
}
|
|
223
|
+
// Build the terraform command
|
|
224
|
+
let command: string;
|
|
96
225
|
|
|
97
|
-
if (input.
|
|
98
|
-
|
|
226
|
+
if (input.action === 'state-list') {
|
|
227
|
+
command = `terraform -chdir=${input.workdir} state list${input.args ? ' ' + input.args : ''}`;
|
|
228
|
+
} else if (input.action === 'state-show') {
|
|
229
|
+
if (!input.state_address) return err('state-show requires state_address');
|
|
230
|
+
command = `terraform -chdir=${input.workdir} state show "${input.state_address}"`;
|
|
231
|
+
} else if (input.action === 'state-rm') {
|
|
232
|
+
if (!input.state_address) return err('state-rm requires state_address');
|
|
233
|
+
command = `terraform -chdir=${input.workdir} state rm "${input.state_address}"`;
|
|
234
|
+
} else if (input.action === 'state-mv') {
|
|
235
|
+
if (!input.state_address) return err('state-mv requires state_address (format: "source dest")');
|
|
236
|
+
command = `terraform -chdir=${input.workdir} state mv ${input.state_address}`;
|
|
237
|
+
} else if (input.action === 'state') {
|
|
238
|
+
command = `terraform -chdir=${input.workdir} state${input.args ? ' ' + input.args : ' list'}`;
|
|
239
|
+
} else if (input.action === 'output') {
|
|
240
|
+
command = `terraform -chdir=${input.workdir} output -json${input.output_name ? ' ' + input.output_name : ''}`;
|
|
241
|
+
} else if (input.action === 'workspace-list') {
|
|
242
|
+
command = `terraform -chdir=${input.workdir} workspace list`;
|
|
243
|
+
} else if (input.action === 'workspace-select') {
|
|
244
|
+
if (!input.workspace) return err('workspace-select requires workspace name');
|
|
245
|
+
command = `terraform -chdir=${input.workdir} workspace select "${input.workspace}"`;
|
|
246
|
+
} else if (input.action === 'workspace-new') {
|
|
247
|
+
if (!input.workspace) return err('workspace-new requires workspace name');
|
|
248
|
+
command = `terraform -chdir=${input.workdir} workspace new "${input.workspace}"`;
|
|
249
|
+
} else if (input.action === 'providers') {
|
|
250
|
+
command = `terraform -chdir=${input.workdir} providers`;
|
|
251
|
+
} else if (input.action === 'graph') {
|
|
252
|
+
command = `terraform -chdir=${input.workdir} graph${input.args ? ' ' + input.args : ''}`;
|
|
253
|
+
} else if (input.action === 'force-unlock') {
|
|
254
|
+
if (!input.lock_id) return err('force-unlock requires lock_id');
|
|
255
|
+
command = `terraform -chdir=${input.workdir} force-unlock -force "${input.lock_id}"`;
|
|
256
|
+
} else {
|
|
257
|
+
const parts: string[] = ['terraform', `-chdir=${input.workdir}`, input.action];
|
|
258
|
+
|
|
259
|
+
if (input.var_file) {
|
|
260
|
+
parts.push(`-var-file=${input.var_file}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Auto-approve for apply/destroy -- the permission engine handles
|
|
264
|
+
// user confirmation before execute() is ever called.
|
|
265
|
+
if (input.action === 'apply' || input.action === 'destroy') {
|
|
266
|
+
parts.push('-auto-approve');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Add -no-color for cleaner output in non-TTY contexts.
|
|
270
|
+
if (['plan', 'apply', 'destroy', 'init'].includes(input.action)) {
|
|
271
|
+
parts.push('-no-color');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// GAP-26: For plan, save the plan to a file so apply can use it
|
|
275
|
+
if (input.action === 'plan') {
|
|
276
|
+
const planFilePath = pathJoin(input.workdir, '.nimbus-plan');
|
|
277
|
+
parts.push(`-out=.nimbus-plan`);
|
|
278
|
+
terraformPlanFiles.set(input.workdir, planFilePath);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// GAP-26: For apply, use the saved plan file if available
|
|
282
|
+
if (input.action === 'apply') {
|
|
283
|
+
const planFile = terraformPlanFiles.get(input.workdir);
|
|
284
|
+
if (planFile && existsSync(planFile)) {
|
|
285
|
+
// Replace the apply command with one that uses the plan file
|
|
286
|
+
// Remove the -auto-approve flag since plan files don't need it
|
|
287
|
+
const applyIdx = parts.indexOf('-auto-approve');
|
|
288
|
+
if (applyIdx !== -1) parts.splice(applyIdx, 1);
|
|
289
|
+
parts.push(planFile);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (input.args) {
|
|
294
|
+
// Strip our internal safety flag before passing to terraform
|
|
295
|
+
const cleanedArgs = input.args.replace('--confirmed-destroy', '').trim();
|
|
296
|
+
if (cleanedArgs) {
|
|
297
|
+
parts.push(cleanedArgs);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
command = parts.join(' ');
|
|
99
301
|
}
|
|
100
302
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
303
|
+
const spawnResult = await spawnExec(command, {
|
|
304
|
+
cwd: input.workdir,
|
|
305
|
+
env: { ...process.env, ...(input.env ?? {}) } as NodeJS.ProcessEnv,
|
|
306
|
+
onChunk: ctx?.onProgress,
|
|
307
|
+
timeout: ctx?.timeout ?? DEFAULT_TIMEOUT, // GAP-20: per-tool timeout from NIMBUS.md, else 10 min default
|
|
105
308
|
});
|
|
106
309
|
|
|
107
|
-
|
|
108
|
-
|
|
310
|
+
if (spawnResult.exitCode !== 0) {
|
|
311
|
+
// GAP-26: Clean up plan file on apply failure
|
|
312
|
+
if (input.action === 'apply') {
|
|
313
|
+
const planFile = terraformPlanFiles.get(input.workdir);
|
|
314
|
+
if (planFile) {
|
|
315
|
+
terraformPlanFiles.delete(input.workdir);
|
|
316
|
+
try { unlinkSync(planFile); } catch { /* ignore */ }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const combinedErr = [spawnResult.stdout, spawnResult.stderr].filter(Boolean).join('\n');
|
|
320
|
+
// Check for state lock error — extract Lock ID for force-unlock hint (M1 / G14)
|
|
321
|
+
const lockMatch = combinedErr.match(/Lock Info[\s\S]*?ID:\s*([a-f0-9-]+)/);
|
|
322
|
+
if (lockMatch) {
|
|
323
|
+
return err(`${combinedErr}\n\nHINT: State is locked. To unlock: terraform force-unlock ${lockMatch[1]}`);
|
|
324
|
+
}
|
|
325
|
+
// G14: Also detect direct "Lock ID:" line format from terraform output
|
|
326
|
+
const lockIdMatch = combinedErr.match(/Lock\s+ID:\s*([a-f0-9-]{36})/i);
|
|
327
|
+
if (lockIdMatch) {
|
|
328
|
+
return err(`${combinedErr}\n\n[STATE LOCK DETECTED] Lock ID: ${lockIdMatch[1]}\nTo force-unlock: terraform force-unlock ${lockIdMatch[1]}\nWARNING: Only force-unlock if no other operations are running.`);
|
|
329
|
+
}
|
|
330
|
+
return err(`Terraform command failed:\n${combinedErr}`);
|
|
331
|
+
}
|
|
332
|
+
const combinedOut = [spawnResult.stdout, spawnResult.stderr].filter(Boolean).join('\n');
|
|
333
|
+
|
|
334
|
+
// GAP-26: Clean up plan file after successful apply
|
|
335
|
+
if (input.action === 'apply') {
|
|
336
|
+
const planFile = terraformPlanFiles.get(input.workdir);
|
|
337
|
+
if (planFile) {
|
|
338
|
+
terraformPlanFiles.delete(input.workdir);
|
|
339
|
+
try { unlinkSync(planFile); } catch { /* ignore */ }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Check for remote backend before mutating actions (M1)
|
|
344
|
+
if (['apply', 'destroy', 'import', 'state-rm'].includes(input.action)) {
|
|
345
|
+
const remoteWarning = await checkRemoteBackend(input.workdir);
|
|
346
|
+
if (remoteWarning) {
|
|
347
|
+
return ok(`${remoteWarning}\n\n${combinedOut || '(no output)'}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return ok(combinedOut || '(no output)');
|
|
109
352
|
} catch (error: unknown) {
|
|
110
353
|
return err(`Terraform command failed: ${errorMessage(error)}`);
|
|
111
354
|
}
|
|
@@ -118,11 +361,20 @@ export const terraformTool: ToolDefinition = {
|
|
|
118
361
|
|
|
119
362
|
const kubectlSchema = z.object({
|
|
120
363
|
action: z
|
|
121
|
-
.enum([
|
|
364
|
+
.enum([
|
|
365
|
+
'get', 'apply', 'delete', 'logs', 'scale', 'rollout', 'exec', 'describe',
|
|
366
|
+
'patch', 'port-forward', 'cp', 'top', 'label', 'annotate',
|
|
367
|
+
'cordon', 'drain', 'taint', 'wait', 'diff',
|
|
368
|
+
])
|
|
122
369
|
.describe('The kubectl sub-command to run'),
|
|
123
370
|
resource: z.string().optional().describe('Resource type and/or name (e.g., "pods my-pod")'),
|
|
124
371
|
namespace: z.string().optional().describe('Kubernetes namespace'),
|
|
125
372
|
args: z.string().optional().describe('Additional CLI arguments'),
|
|
373
|
+
patch_type: z.enum(['strategic', 'merge', 'json']).optional().describe('Patch type for patch action'),
|
|
374
|
+
patch: z.string().optional().describe('JSON patch string for patch action'),
|
|
375
|
+
local_path: z.string().optional().describe('Local path for cp action'),
|
|
376
|
+
container_path: z.string().optional().describe('Container path for cp action'),
|
|
377
|
+
env: z.record(z.string(), z.string()).optional().describe('Extra environment variables (e.g., KUBECONFIG, AWS_PROFILE)'),
|
|
126
378
|
});
|
|
127
379
|
|
|
128
380
|
export const kubectlTool: ToolDefinition = {
|
|
@@ -133,31 +385,103 @@ export const kubectlTool: ToolDefinition = {
|
|
|
133
385
|
category: 'devops',
|
|
134
386
|
isDestructive: true,
|
|
135
387
|
|
|
136
|
-
async execute(raw: unknown): Promise<ToolResult> {
|
|
388
|
+
async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
|
|
137
389
|
try {
|
|
138
390
|
const input = kubectlSchema.parse(raw);
|
|
139
391
|
|
|
140
|
-
|
|
392
|
+
// C2: Use session infraContext as kubectl context fallback
|
|
393
|
+
const contextFlag = ctx?.infraContext?.kubectlContext
|
|
394
|
+
? `--context=${ctx.infraContext.kubectlContext} `
|
|
395
|
+
: '';
|
|
141
396
|
|
|
142
|
-
|
|
143
|
-
parts.push(input.resource);
|
|
144
|
-
}
|
|
397
|
+
const parts: string[] = ['kubectl', input.action];
|
|
145
398
|
|
|
146
|
-
|
|
147
|
-
|
|
399
|
+
// Special handling for new actions
|
|
400
|
+
if (input.action === 'patch') {
|
|
401
|
+
const patchType = input.patch_type ?? 'strategic';
|
|
402
|
+
if (!input.patch) return err('patch action requires patch field with JSON patch string');
|
|
403
|
+
if (input.resource) parts.push(input.resource);
|
|
404
|
+
if (input.namespace) parts.push('-n', input.namespace);
|
|
405
|
+
parts.push(`--type=${patchType}`);
|
|
406
|
+
parts.push('-p', `'${input.patch}'`);
|
|
407
|
+
} else if (input.action === 'port-forward') {
|
|
408
|
+
if (input.resource) parts.push(input.resource);
|
|
409
|
+
if (input.namespace) parts.push('-n', input.namespace);
|
|
410
|
+
if (input.args) parts.push(input.args);
|
|
411
|
+
} else if (input.action === 'cp') {
|
|
412
|
+
if (input.local_path && input.container_path) {
|
|
413
|
+
parts.push(input.local_path, input.container_path);
|
|
414
|
+
} else {
|
|
415
|
+
if (input.args) parts.push(input.args);
|
|
416
|
+
}
|
|
417
|
+
} else if (input.action === 'top') {
|
|
418
|
+
if (input.resource) parts.push(input.resource);
|
|
419
|
+
if (input.namespace) parts.push('-n', input.namespace);
|
|
420
|
+
if (input.args) parts.push(input.args);
|
|
421
|
+
} else if (input.action === 'cordon' || input.action === 'taint') {
|
|
422
|
+
if (input.resource) parts.push(input.resource);
|
|
423
|
+
if (input.args) parts.push(input.args);
|
|
424
|
+
} else if (input.action === 'drain') {
|
|
425
|
+
if (input.resource) parts.push(input.resource);
|
|
426
|
+
parts.push('--ignore-daemonsets', '--delete-emptydir-data');
|
|
427
|
+
if (input.args) parts.push(input.args);
|
|
428
|
+
} else if (input.action === 'wait') {
|
|
429
|
+
if (input.resource) parts.push(input.resource);
|
|
430
|
+
if (input.namespace) parts.push('-n', input.namespace);
|
|
431
|
+
if (input.args) parts.push(input.args);
|
|
432
|
+
else parts.push('--for=condition=Ready', '--timeout=120s');
|
|
433
|
+
} else if (input.action === 'diff') {
|
|
434
|
+
// G12: kubectl diff — exit code 1 means diffs exist (not an error)
|
|
435
|
+
const manifest = input.args || '-';
|
|
436
|
+
const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
|
|
437
|
+
const diffCmd = ['kubectl', 'diff', '-f', manifest, nsFlag].filter(Boolean).join(' ');
|
|
438
|
+
try {
|
|
439
|
+
const { stdout: diffOut } = await execAsync(diffCmd, { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 });
|
|
440
|
+
return ok(diffOut.trim() || 'No differences found — manifests match cluster state.');
|
|
441
|
+
} catch (diffErr: unknown) {
|
|
442
|
+
const execError = diffErr as { stdout?: string; stderr?: string; code?: number };
|
|
443
|
+
// Exit code 1 with stdout = normal diff output (changes detected)
|
|
444
|
+
if (execError.code === 1 && execError.stdout) return ok(execError.stdout.trim());
|
|
445
|
+
return err(errorMessage(diffErr));
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
if (input.resource) {
|
|
449
|
+
parts.push(input.resource);
|
|
450
|
+
}
|
|
451
|
+
if (input.namespace) {
|
|
452
|
+
parts.push('-n', input.namespace);
|
|
453
|
+
}
|
|
454
|
+
if (input.args) {
|
|
455
|
+
parts.push(input.args);
|
|
456
|
+
}
|
|
148
457
|
}
|
|
149
458
|
|
|
150
|
-
|
|
151
|
-
|
|
459
|
+
const rawCommand = parts.join(' ');
|
|
460
|
+
// C2: Inject kubectl context from session infraContext if not already specified
|
|
461
|
+
const command = contextFlag && !rawCommand.includes('--context=')
|
|
462
|
+
? rawCommand.replace('kubectl ', `kubectl ${contextFlag}`)
|
|
463
|
+
: rawCommand;
|
|
464
|
+
const streamingActions = ['apply', 'delete', 'rollout', 'port-forward'];
|
|
465
|
+
if (ctx?.onProgress && streamingActions.includes(input.action)) {
|
|
466
|
+
const defaultKubectlTimeoutMs = input.action === 'port-forward' ? 300_000 : 120_000;
|
|
467
|
+
const timeoutMs = ctx?.timeout ?? defaultKubectlTimeoutMs; // GAP-20: per-tool timeout from NIMBUS.md
|
|
468
|
+
const result = await spawnExec(command, { onChunk: ctx.onProgress, timeout: timeoutMs });
|
|
469
|
+
const combined = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
470
|
+
if (result.exitCode !== 0) return err(`kubectl command failed:\n${combined}`);
|
|
471
|
+
return ok(combined || '(no output)');
|
|
152
472
|
}
|
|
153
|
-
|
|
154
|
-
const command = parts.join(' ');
|
|
473
|
+
const cmdEnv = { ...process.env, ...(input.env ?? {}) } as NodeJS.ProcessEnv;
|
|
155
474
|
const { stdout, stderr } = await execAsync(command, {
|
|
156
475
|
timeout: 120_000,
|
|
157
476
|
maxBuffer: 10 * 1024 * 1024,
|
|
477
|
+
env: cmdEnv,
|
|
158
478
|
});
|
|
159
479
|
|
|
160
|
-
|
|
480
|
+
let combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
481
|
+
// H6: Format pod output with status emoji for scannability
|
|
482
|
+
if (input.action === 'get' && input.resource && /\bpods?\b/i.test(input.resource)) {
|
|
483
|
+
combined = formatKubectlPodsOutput(combined);
|
|
484
|
+
}
|
|
161
485
|
return ok(combined || '(no output)');
|
|
162
486
|
} catch (error: unknown) {
|
|
163
487
|
return err(`kubectl command failed: ${errorMessage(error)}`);
|
|
@@ -171,14 +495,28 @@ export const kubectlTool: ToolDefinition = {
|
|
|
171
495
|
|
|
172
496
|
const helmSchema = z.object({
|
|
173
497
|
action: z
|
|
174
|
-
.enum([
|
|
498
|
+
.enum([
|
|
499
|
+
'install', 'upgrade', 'uninstall', 'list', 'rollback', 'template', 'lint',
|
|
500
|
+
'secrets-encrypt', 'secrets-decrypt', 'secrets-view',
|
|
501
|
+
'get-values', 'get-manifest', 'get-all', 'get-hooks', 'status', 'history',
|
|
502
|
+
'test', 'repo-add', 'repo-update', 'repo-list', 'search-repo',
|
|
503
|
+
'show-chart', 'show-values',
|
|
504
|
+
])
|
|
175
505
|
.describe('The Helm sub-command to run'),
|
|
176
506
|
release: z.string().optional().describe('Helm release name'),
|
|
177
507
|
chart: z.string().optional().describe('Chart reference (e.g., "bitnami/nginx")'),
|
|
178
|
-
values: z.string().optional().describe('Path to a values.yaml file'),
|
|
508
|
+
values: z.string().optional().describe('Path to a values.yaml or SOPS-encrypted values file'),
|
|
179
509
|
namespace: z.string().optional().describe('Kubernetes namespace for the release'),
|
|
510
|
+
revision: z.number().optional().describe('Release revision number (for history/rollback)'),
|
|
511
|
+
repo_name: z.string().optional().describe('Helm repo name (for repo-add)'),
|
|
512
|
+
repo_url: z.string().optional().describe('Helm repo URL (for repo-add)'),
|
|
513
|
+
env: z.record(z.string(), z.string()).optional().describe('Extra environment variables passed to helm'),
|
|
180
514
|
});
|
|
181
515
|
|
|
516
|
+
/** Last time `helm repo update` was auto-run (prevents repeated runs). */
|
|
517
|
+
let lastHelmRepoUpdate = 0;
|
|
518
|
+
const HELM_REPO_UPDATE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
519
|
+
|
|
182
520
|
export const helmTool: ToolDefinition = {
|
|
183
521
|
name: 'helm',
|
|
184
522
|
description: 'Execute Helm operations for Kubernetes package management.',
|
|
@@ -187,10 +525,140 @@ export const helmTool: ToolDefinition = {
|
|
|
187
525
|
category: 'devops',
|
|
188
526
|
isDestructive: true,
|
|
189
527
|
|
|
190
|
-
async execute(raw: unknown): Promise<ToolResult> {
|
|
528
|
+
async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
|
|
191
529
|
try {
|
|
192
530
|
const input = helmSchema.parse(raw);
|
|
193
531
|
|
|
532
|
+
// M5: Helm secrets plugin actions (SOPS-encrypted values)
|
|
533
|
+
if (input.action === 'secrets-encrypt' || input.action === 'secrets-decrypt' || input.action === 'secrets-view') {
|
|
534
|
+
const file = input.values;
|
|
535
|
+
if (!file) return err('helm secrets requires a values file path (values field)');
|
|
536
|
+
const secretsAction = input.action.replace('secrets-', '');
|
|
537
|
+
const command = `helm secrets ${secretsAction} ${file}`;
|
|
538
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
539
|
+
timeout: 60_000,
|
|
540
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
541
|
+
});
|
|
542
|
+
return ok([stdout, stderr].filter(Boolean).join('\n') || '(no output)');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// New introspection/repo actions
|
|
546
|
+
if (['get-values', 'get-manifest', 'get-all', 'get-hooks'].includes(input.action)) {
|
|
547
|
+
if (!input.release) return err(`${input.action} requires a release name`);
|
|
548
|
+
const subCmd = input.action.replace('get-', 'get ');
|
|
549
|
+
const nsFlag = input.namespace ? ` -n ${input.namespace}` : '';
|
|
550
|
+
const { stdout: getOut, stderr: getErr } = await execAsync(
|
|
551
|
+
`helm ${subCmd} ${input.release}${nsFlag}`,
|
|
552
|
+
{ timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
|
|
553
|
+
);
|
|
554
|
+
return ok([getOut, getErr].filter(Boolean).join('\n') || '(no output)');
|
|
555
|
+
}
|
|
556
|
+
if (input.action === 'status') {
|
|
557
|
+
if (!input.release) return err('status requires a release name');
|
|
558
|
+
const nsFlag = input.namespace ? ` -n ${input.namespace}` : '';
|
|
559
|
+
const { stdout: statusOut, stderr: statusErr } = await execAsync(
|
|
560
|
+
`helm status ${input.release}${nsFlag}`,
|
|
561
|
+
{ timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
|
|
562
|
+
);
|
|
563
|
+
return ok([statusOut, statusErr].filter(Boolean).join('\n') || '(no output)');
|
|
564
|
+
}
|
|
565
|
+
if (input.action === 'history') {
|
|
566
|
+
if (!input.release) return err('history requires a release name');
|
|
567
|
+
const nsFlag = input.namespace ? ` -n ${input.namespace}` : '';
|
|
568
|
+
try {
|
|
569
|
+
const { stdout: histOut } = await execAsync(
|
|
570
|
+
`helm history ${input.release}${nsFlag} --max 10 --output json`,
|
|
571
|
+
{ timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
|
|
572
|
+
);
|
|
573
|
+
const histData: Array<{revision: number; updated: string; status: string; chart: string; description: string}> = JSON.parse(histOut || '[]');
|
|
574
|
+
const lines = histData.map(h => ` Rev ${h.revision}: ${h.chart} [${h.status}] ${h.updated} — ${h.description}`);
|
|
575
|
+
return ok(`Release history for ${input.release}:\n${lines.join('\n')}`);
|
|
576
|
+
} catch {
|
|
577
|
+
const { stdout: histOut2, stderr: histErr2 } = await execAsync(
|
|
578
|
+
`helm history ${input.release}${nsFlag}`,
|
|
579
|
+
{ timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
|
|
580
|
+
);
|
|
581
|
+
return ok([histOut2, histErr2].filter(Boolean).join('\n') || '(no output)');
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (input.action === 'test') {
|
|
585
|
+
if (!input.release) return err('test requires a release name');
|
|
586
|
+
const nsFlag = input.namespace ? ` -n ${input.namespace}` : '';
|
|
587
|
+
const { stdout: testOut, stderr: testErr } = await execAsync(
|
|
588
|
+
`helm test ${input.release}${nsFlag}`,
|
|
589
|
+
{ timeout: 120_000, maxBuffer: 5 * 1024 * 1024 }
|
|
590
|
+
);
|
|
591
|
+
return ok([testOut, testErr].filter(Boolean).join('\n') || '(no output)');
|
|
592
|
+
}
|
|
593
|
+
if (input.action === 'repo-add') {
|
|
594
|
+
if (!input.repo_name || !input.repo_url) return err('repo-add requires repo_name and repo_url');
|
|
595
|
+
const { stdout: raOut, stderr: raErr } = await execAsync(
|
|
596
|
+
`helm repo add ${input.repo_name} ${input.repo_url}`,
|
|
597
|
+
{ timeout: 30_000, maxBuffer: 1 * 1024 * 1024 }
|
|
598
|
+
);
|
|
599
|
+
return ok([raOut, raErr].filter(Boolean).join('\n') || '(no output)');
|
|
600
|
+
}
|
|
601
|
+
if (input.action === 'repo-update') {
|
|
602
|
+
const { stdout: ruOut, stderr: ruErr } = await execAsync(
|
|
603
|
+
'helm repo update',
|
|
604
|
+
{ timeout: 60_000, maxBuffer: 2 * 1024 * 1024 }
|
|
605
|
+
);
|
|
606
|
+
return ok([ruOut, ruErr].filter(Boolean).join('\n') || '(no output)');
|
|
607
|
+
}
|
|
608
|
+
if (input.action === 'repo-list') {
|
|
609
|
+
const { stdout: rlOut, stderr: rlErr } = await execAsync(
|
|
610
|
+
'helm repo list --output json',
|
|
611
|
+
{ timeout: 30_000, maxBuffer: 2 * 1024 * 1024 }
|
|
612
|
+
);
|
|
613
|
+
return ok([rlOut, rlErr].filter(Boolean).join('\n') || '(no repos configured)');
|
|
614
|
+
}
|
|
615
|
+
if (input.action === 'search-repo') {
|
|
616
|
+
const query = input.chart ?? input.release ?? '';
|
|
617
|
+
if (!query) return err('search-repo requires chart or release field as search term');
|
|
618
|
+
const { stdout: srOut, stderr: srErr } = await execAsync(
|
|
619
|
+
`helm search repo ${query}`,
|
|
620
|
+
{ timeout: 30_000, maxBuffer: 2 * 1024 * 1024 }
|
|
621
|
+
);
|
|
622
|
+
return ok([srOut, srErr].filter(Boolean).join('\n') || '(no results)');
|
|
623
|
+
}
|
|
624
|
+
if (input.action === 'show-chart' || input.action === 'show-values') {
|
|
625
|
+
const target = input.chart ?? input.release;
|
|
626
|
+
if (!target) return err(`${input.action} requires chart or release field`);
|
|
627
|
+
const subCmd = input.action === 'show-chart' ? 'chart' : 'values';
|
|
628
|
+
const { stdout: showOut, stderr: showErr } = await execAsync(
|
|
629
|
+
`helm show ${subCmd} ${target}`,
|
|
630
|
+
{ timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
|
|
631
|
+
);
|
|
632
|
+
return ok([showOut, showErr].filter(Boolean).join('\n') || '(no output)');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// H6: helm list — use JSON output for formatted display
|
|
636
|
+
if (input.action === 'list') {
|
|
637
|
+
const nsFlag = input.namespace ? ` -n ${input.namespace}` : ' -A';
|
|
638
|
+
try {
|
|
639
|
+
const { stdout: listJson } = await execAsync(`helm list -o json${nsFlag}`, {
|
|
640
|
+
timeout: 30_000,
|
|
641
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
642
|
+
});
|
|
643
|
+
return ok(formatHelmListOutput(listJson));
|
|
644
|
+
} catch {
|
|
645
|
+
// Fall through to plain helm list
|
|
646
|
+
const { stdout: listOut, stderr: listErr } = await execAsync(`helm list${nsFlag}`, {
|
|
647
|
+
timeout: 30_000,
|
|
648
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
649
|
+
});
|
|
650
|
+
return ok([listOut, listErr].filter(Boolean).join('\n') || '(no releases found)');
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// G17: Auto-update helm repos if cache is stale (>1 hour) before install/upgrade
|
|
655
|
+
if ((input.action === 'install' || input.action === 'upgrade') && Date.now() - lastHelmRepoUpdate > HELM_REPO_UPDATE_INTERVAL_MS) {
|
|
656
|
+
try {
|
|
657
|
+
await execAsync('helm repo update', { timeout: 30000 });
|
|
658
|
+
lastHelmRepoUpdate = Date.now();
|
|
659
|
+
} catch { /* non-critical — proceed with install/upgrade */ }
|
|
660
|
+
}
|
|
661
|
+
|
|
194
662
|
const parts: string[] = ['helm', input.action];
|
|
195
663
|
|
|
196
664
|
if (input.release) {
|
|
@@ -210,9 +678,21 @@ export const helmTool: ToolDefinition = {
|
|
|
210
678
|
}
|
|
211
679
|
|
|
212
680
|
const command = parts.join(' ');
|
|
681
|
+
// G10: stream output for long-running helm actions so users see progress
|
|
682
|
+
const HELM_STREAMING_ACTIONS = new Set(['install', 'upgrade', 'rollback', 'uninstall']);
|
|
683
|
+
if (HELM_STREAMING_ACTIONS.has(input.action)) {
|
|
684
|
+
const { stdout: sout, stderr: serr } = await spawnExec(command, {
|
|
685
|
+
onChunk: ctx?.onProgress,
|
|
686
|
+
timeout: ctx?.timeout ?? DEFAULT_TIMEOUT, // GAP-20: per-tool timeout from NIMBUS.md, else 10 min default
|
|
687
|
+
});
|
|
688
|
+
const combined = [sout, serr].filter(Boolean).join('\n');
|
|
689
|
+
return ok(combined.trim() || '(no output)');
|
|
690
|
+
}
|
|
691
|
+
const helmEnv = { ...process.env, ...(input.env ?? {}) } as NodeJS.ProcessEnv;
|
|
213
692
|
const { stdout, stderr } = await execAsync(command, {
|
|
214
693
|
timeout: 300_000, // 5 minutes
|
|
215
694
|
maxBuffer: 10 * 1024 * 1024,
|
|
695
|
+
env: helmEnv,
|
|
216
696
|
});
|
|
217
697
|
|
|
218
698
|
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
@@ -231,8 +711,11 @@ const cloudDiscoverSchema = z.object({
|
|
|
231
711
|
provider: z.enum(['aws', 'gcp', 'azure']).describe('Cloud provider to discover resources from'),
|
|
232
712
|
resource_type: z
|
|
233
713
|
.string()
|
|
234
|
-
.describe(
|
|
714
|
+
.describe(
|
|
715
|
+
'Full CLI service and command for the provider. AWS: "ec2 describe-instances", "s3api list-buckets", "rds describe-db-instances", "lambda list-functions", "eks list-clusters". GCP: "compute instances list", "container clusters list". Azure: "vm list".'
|
|
716
|
+
),
|
|
235
717
|
region: z.string().optional().describe('Cloud region to scope the discovery'),
|
|
718
|
+
regions: z.array(z.string()).optional().describe('Multiple regions for parallel discovery (max 5 concurrent)'),
|
|
236
719
|
});
|
|
237
720
|
|
|
238
721
|
export const cloudDiscoverTool: ToolDefinition = {
|
|
@@ -247,17 +730,66 @@ export const cloudDiscoverTool: ToolDefinition = {
|
|
|
247
730
|
try {
|
|
248
731
|
const input = cloudDiscoverSchema.parse(raw);
|
|
249
732
|
|
|
733
|
+
// H2: Multi-region parallel discovery
|
|
734
|
+
const targetRegions = input.regions && input.regions.length > 0
|
|
735
|
+
? input.regions.slice(0, 10) // cap at 10 regions
|
|
736
|
+
: input.region ? [input.region] : [undefined];
|
|
737
|
+
|
|
738
|
+
if (targetRegions.length > 1) {
|
|
739
|
+
// Run up to 5 regions concurrently
|
|
740
|
+
const concurrencyLimit = 5;
|
|
741
|
+
const allResults: string[] = [];
|
|
742
|
+
for (let i = 0; i < targetRegions.length; i += concurrencyLimit) {
|
|
743
|
+
const chunk = targetRegions.slice(i, i + concurrencyLimit);
|
|
744
|
+
const chunkResults = await Promise.allSettled(
|
|
745
|
+
chunk.map(async (region) => {
|
|
746
|
+
let cmd: string;
|
|
747
|
+
switch (input.provider) {
|
|
748
|
+
case 'aws': {
|
|
749
|
+
const rf = region ? ` --region ${region}` : '';
|
|
750
|
+
cmd = `aws ${input.resource_type}${rf} --output json`;
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
case 'gcp': {
|
|
754
|
+
const rf = region ? ` --regions=${region}` : '';
|
|
755
|
+
cmd = `gcloud ${input.resource_type}${rf} --format json`;
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
case 'azure': {
|
|
759
|
+
cmd = `az ${input.resource_type} list --output json`;
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
default:
|
|
763
|
+
cmd = '';
|
|
764
|
+
}
|
|
765
|
+
const { stdout, stderr } = await execAsync(cmd, { timeout: 60_000, maxBuffer: 5 * 1024 * 1024 });
|
|
766
|
+
return { region: region ?? 'default', output: [stdout, stderr].filter(Boolean).join('\n') };
|
|
767
|
+
})
|
|
768
|
+
);
|
|
769
|
+
for (const res of chunkResults) {
|
|
770
|
+
if (res.status === 'fulfilled') {
|
|
771
|
+
allResults.push(`\n## Region: ${res.value.region}\n${res.value.output}`);
|
|
772
|
+
} else {
|
|
773
|
+
allResults.push(`\n## Region: ${chunk[chunkResults.indexOf(res)]} — Error: ${res.reason}`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return ok(allResults.join('\n') || 'No resources found across specified regions.');
|
|
778
|
+
}
|
|
779
|
+
|
|
250
780
|
let command: string;
|
|
251
781
|
|
|
252
782
|
switch (input.provider) {
|
|
253
783
|
case 'aws': {
|
|
254
784
|
const regionFlag = input.region ? ` --region ${input.region}` : '';
|
|
255
|
-
|
|
785
|
+
// resource_type is the full service+command, e.g. "ec2 describe-instances", "s3api list-buckets"
|
|
786
|
+
command = `aws ${input.resource_type}${regionFlag} --output json`;
|
|
256
787
|
break;
|
|
257
788
|
}
|
|
258
789
|
case 'gcp': {
|
|
259
790
|
const regionFlag = input.region ? ` --regions=${input.region}` : '';
|
|
260
|
-
|
|
791
|
+
// resource_type is the full subcommand, e.g. "compute instances list", "container clusters list"
|
|
792
|
+
command = `gcloud ${input.resource_type}${regionFlag} --format json`;
|
|
261
793
|
break;
|
|
262
794
|
}
|
|
263
795
|
case 'azure': {
|
|
@@ -272,7 +804,99 @@ export const cloudDiscoverTool: ToolDefinition = {
|
|
|
272
804
|
});
|
|
273
805
|
|
|
274
806
|
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
275
|
-
|
|
807
|
+
|
|
808
|
+
// Parse and summarize JSON output for readability
|
|
809
|
+
try {
|
|
810
|
+
const data = JSON.parse(combined);
|
|
811
|
+
const items = Array.isArray(data) ? data : (data.Reservations ? data.Reservations.flatMap((r: { Instances?: unknown[] }) => r.Instances ?? []) : [data]);
|
|
812
|
+
if (items.length === 0) {
|
|
813
|
+
return ok('No resources found.');
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Build structured per-resource-type summary
|
|
817
|
+
const summary = items.slice(0, 50).map((item: Record<string, unknown>) => {
|
|
818
|
+
// Security flags
|
|
819
|
+
const securityFlags: string[] = [];
|
|
820
|
+
|
|
821
|
+
// EC2 instance formatter
|
|
822
|
+
if (item.InstanceId || item.InstanceType) {
|
|
823
|
+
const name = (item.Tags as Array<{ Key: string; Value: string }>)?.find(t => t.Key === 'Name')?.Value ?? item.InstanceId ?? '(unnamed)';
|
|
824
|
+
const state = (item.State as Record<string, unknown>)?.Name ?? item.state ?? '';
|
|
825
|
+
const az = (item.Placement as Record<string, unknown>)?.AvailabilityZone ?? '';
|
|
826
|
+
const publicIp = item.PublicIpAddress ?? '';
|
|
827
|
+
const privateIp = item.PrivateIpAddress ?? '';
|
|
828
|
+
const sgs = (item.SecurityGroups as Array<Record<string, unknown>> | undefined) ?? [];
|
|
829
|
+
if (sgs.length > 0) securityFlags.push('check-sg-rules');
|
|
830
|
+
const flagStr = securityFlags.length > 0 ? ` [${securityFlags.join(', ')}]` : '';
|
|
831
|
+
return ` - EC2: ${name} (${item.InstanceType ?? ''}) ${state}${az ? ` [${az}]` : ''}${publicIp ? ` pub:${publicIp}` : ''}${privateIp ? ` priv:${privateIp}` : ''}${flagStr}`;
|
|
832
|
+
}
|
|
833
|
+
// RDS formatter
|
|
834
|
+
if (item.DBInstanceIdentifier) {
|
|
835
|
+
const id = item.DBInstanceIdentifier as string;
|
|
836
|
+
const engine = `${item.Engine ?? ''}${item.EngineVersion ? ' ' + item.EngineVersion : ''}`;
|
|
837
|
+
const status = item.DBInstanceStatus ?? '';
|
|
838
|
+
const multiAz = item.MultiAZ ? 'Multi-AZ' : 'Single-AZ';
|
|
839
|
+
const endpoint = (item.Endpoint as Record<string, unknown>)?.Address ?? '';
|
|
840
|
+
if (!item.StorageEncrypted) securityFlags.push('unencrypted');
|
|
841
|
+
const flagStr = securityFlags.length > 0 ? ` [${securityFlags.join(', ')}]` : '';
|
|
842
|
+
return ` - RDS: ${id} (${engine}) ${status} ${multiAz}${endpoint ? ` -> ${endpoint}` : ''}${flagStr}`;
|
|
843
|
+
}
|
|
844
|
+
// EKS formatter
|
|
845
|
+
if ((item.arn && String(item.arn).includes(':cluster/')) || (item.ClusterName && item.kubernetesNetworkConfig)) {
|
|
846
|
+
const name = item.name ?? item.ClusterName ?? '(unnamed)';
|
|
847
|
+
const version = item.version ?? item.Version ?? '';
|
|
848
|
+
const status = item.status ?? item.Status ?? '';
|
|
849
|
+
return ` - EKS: ${name} (k8s ${version}) ${status}`;
|
|
850
|
+
}
|
|
851
|
+
// S3 formatter
|
|
852
|
+
if (item.BucketName || (item.Name && !item.InstanceType && !item.DBInstanceIdentifier)) {
|
|
853
|
+
const name = item.BucketName ?? item.Name ?? '(unnamed)';
|
|
854
|
+
const region = item.LocationConstraint ?? item.region ?? '';
|
|
855
|
+
if (item.PublicAccessBlockConfiguration && !(item.PublicAccessBlockConfiguration as Record<string, unknown>).BlockPublicAcls) {
|
|
856
|
+
securityFlags.push('public-access');
|
|
857
|
+
}
|
|
858
|
+
const flagStr = securityFlags.length > 0 ? ` [${securityFlags.join(', ')}]` : '';
|
|
859
|
+
return ` - S3: ${name}${region ? ` [${region}]` : ''}${flagStr}`;
|
|
860
|
+
}
|
|
861
|
+
// GCE formatter
|
|
862
|
+
if (item.machineType || (item.kind && String(item.kind).includes('Instance'))) {
|
|
863
|
+
const name = item.name ?? '(unnamed)';
|
|
864
|
+
const machineType = String(item.machineType ?? '').split('/').pop() ?? '';
|
|
865
|
+
const status = item.status ?? '';
|
|
866
|
+
const zone = String(item.zone ?? '').split('/').pop() ?? '';
|
|
867
|
+
const networkInterfaces = item.networkInterfaces as Array<Record<string, unknown>> | undefined;
|
|
868
|
+
const extIp = networkInterfaces?.[0]?.accessConfigs
|
|
869
|
+
? (networkInterfaces[0].accessConfigs as Array<Record<string, unknown>>)?.[0]?.natIP ?? ''
|
|
870
|
+
: '';
|
|
871
|
+
return ` - GCE: ${name} (${machineType}) ${status}${zone ? ` [${zone}]` : ''}${extIp ? ` pub:${extIp}` : ''}`;
|
|
872
|
+
}
|
|
873
|
+
// AKS formatter
|
|
874
|
+
if (item.type && String(item.type).includes('managedClusters')) {
|
|
875
|
+
const name = item.name ?? '(unnamed)';
|
|
876
|
+
const location = item.location ?? '';
|
|
877
|
+
const k8sVersion = (item.properties as Record<string, unknown>)?.kubernetesVersion ?? '';
|
|
878
|
+
const agentCount = ((item.properties as Record<string, unknown>)?.agentPoolProfiles as unknown[])?.length ?? 0;
|
|
879
|
+
return ` - AKS: ${name} (k8s ${k8sVersion}) ${location ? `[${location}]` : ''} ${agentCount} agent pool(s)`;
|
|
880
|
+
}
|
|
881
|
+
// Generic fallback
|
|
882
|
+
const name =
|
|
883
|
+
(item.Tags as Array<{ Key: string; Value: string }>)?.find((t) => t.Key === 'Name')?.Value ||
|
|
884
|
+
item.DBInstanceIdentifier || item.FunctionName || item.ClusterName || item.BucketName ||
|
|
885
|
+
item.Name || item.name || (item.metadata as Record<string, unknown>)?.name ||
|
|
886
|
+
item.InstanceId || item.id || '(unnamed)';
|
|
887
|
+
const type = item.InstanceType || item.DBInstanceClass || item.Runtime || item.Status || item.state || item.status || '';
|
|
888
|
+
const region = (item.Placement as Record<string, unknown>)?.AvailabilityZone || (item.DBInstanceArn as string | undefined)?.split(':')[3] || item.region || '';
|
|
889
|
+
return ` - ${name}${type ? ` (${type})` : ''}${region ? ` [${region}]` : ''}`;
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
return ok(
|
|
893
|
+
`Found ${items.length} resource(s):\n${summary.join('\n')}` +
|
|
894
|
+
(items.length > 50 ? `\n\n[+${items.length - 50} more — use specific region/filter to narrow]` : '')
|
|
895
|
+
);
|
|
896
|
+
} catch {
|
|
897
|
+
// Not JSON or failed to parse — return raw output truncated
|
|
898
|
+
return ok((combined || '(no resources found)').slice(0, 10_000));
|
|
899
|
+
}
|
|
276
900
|
} catch (error: unknown) {
|
|
277
901
|
return err(`Cloud discovery failed: ${errorMessage(error)}`);
|
|
278
902
|
}
|
|
@@ -286,11 +910,20 @@ export const cloudDiscoverTool: ToolDefinition = {
|
|
|
286
910
|
const costEstimateSchema = z.object({
|
|
287
911
|
plan_file: z.string().optional().describe('Path to a saved Terraform plan file'),
|
|
288
912
|
workdir: z.string().optional().describe('Working directory containing Terraform configuration'),
|
|
913
|
+
action: z.enum(['estimate', 'compare', 'savings-plan', 'rightsizing', 'budget'])
|
|
914
|
+
.optional().default('estimate').describe('Cost action to perform (default: estimate)'),
|
|
915
|
+
provider: z.enum(['aws', 'gcp', 'azure']).optional().describe('Cloud provider for savings/rightsizing/budget actions'),
|
|
916
|
+
region: z.string().optional().describe('Cloud region for budget/savings queries'),
|
|
917
|
+
/** Gap 13: target compute platform for non-Terraform estimates */
|
|
918
|
+
target: z.enum(['terraform', 'kubernetes', 'ecs', 'lambda', 'gcp-gke', 'azure-aks'])
|
|
919
|
+
.optional().default('terraform').describe('Target platform for cost estimation (default: terraform)'),
|
|
920
|
+
namespace: z.string().optional().describe('Kubernetes namespace for k8s cost estimation'),
|
|
921
|
+
function_name: z.string().optional().describe('Lambda function name for serverless cost estimation'),
|
|
289
922
|
});
|
|
290
923
|
|
|
291
924
|
export const costEstimateTool: ToolDefinition = {
|
|
292
925
|
name: 'cost_estimate',
|
|
293
|
-
description: 'Estimate infrastructure costs
|
|
926
|
+
description: 'Estimate infrastructure costs, compare across providers, check savings plans, rightsizing, or budgets.',
|
|
294
927
|
inputSchema: costEstimateSchema,
|
|
295
928
|
permissionTier: 'auto_allow',
|
|
296
929
|
category: 'devops',
|
|
@@ -299,6 +932,116 @@ export const costEstimateTool: ToolDefinition = {
|
|
|
299
932
|
try {
|
|
300
933
|
const input = costEstimateSchema.parse(raw);
|
|
301
934
|
|
|
935
|
+
// M6: multi-cloud cost actions
|
|
936
|
+
if (input.action === 'savings-plan') {
|
|
937
|
+
const p = input.provider ?? 'aws';
|
|
938
|
+
try {
|
|
939
|
+
if (p === 'aws') {
|
|
940
|
+
const { stdout } = await execAsync('aws ce get-savings-plans-utilization --time-period Start=$(date -v-30d +%Y-%m-%d),End=$(date +%Y-%m-%d) --output json', { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
|
941
|
+
return ok(`AWS Savings Plans Utilization:\n${stdout.slice(0, 5000)}`);
|
|
942
|
+
} else if (p === 'gcp') {
|
|
943
|
+
const { stdout } = await execAsync('gcloud billing accounts list --format=json', { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
|
944
|
+
return ok(`GCP Billing Accounts:\n${stdout.slice(0, 5000)}`);
|
|
945
|
+
}
|
|
946
|
+
return err(`Savings plan query not supported for provider: ${p}`);
|
|
947
|
+
} catch (error) { return err(`Savings plan query failed: ${errorMessage(error)}`); }
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (input.action === 'rightsizing') {
|
|
951
|
+
try {
|
|
952
|
+
const { stdout } = await execAsync('aws ce get-rightsizing-recommendation --service AmazonEC2 --output json', { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
|
953
|
+
return ok(`AWS Rightsizing Recommendations:\n${stdout.slice(0, 5000)}`);
|
|
954
|
+
} catch (error) { return err(`Rightsizing query failed: ${errorMessage(error)}`); }
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (input.action === 'budget') {
|
|
958
|
+
const p = input.provider ?? 'aws';
|
|
959
|
+
try {
|
|
960
|
+
if (p === 'aws') {
|
|
961
|
+
const acct = (await execAsync('aws sts get-caller-identity --query Account --output text', { timeout: 10_000 })).stdout.trim();
|
|
962
|
+
const { stdout } = await execAsync(`aws budgets describe-budgets --account-id ${acct} --output json`, { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
|
963
|
+
return ok(`AWS Budgets:\n${stdout.slice(0, 5000)}`);
|
|
964
|
+
} else if (p === 'gcp') {
|
|
965
|
+
const { stdout } = await execAsync('gcloud billing budgets list --format=json', { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
|
966
|
+
return ok(`GCP Budgets:\n${stdout.slice(0, 5000)}`);
|
|
967
|
+
}
|
|
968
|
+
return err(`Budget query not supported for provider: ${p}`);
|
|
969
|
+
} catch (error) { return err(`Budget query failed: ${errorMessage(error)}`); }
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (input.action === 'compare') {
|
|
973
|
+
// Run infracost for current workdir and summarize
|
|
974
|
+
const cwd = input.workdir ?? '.';
|
|
975
|
+
try {
|
|
976
|
+
const { stdout } = await execAsync(`infracost breakdown --path ${cwd} --format json`, { timeout: 60_000, maxBuffer: 5 * 1024 * 1024 });
|
|
977
|
+
const ic = JSON.parse(stdout);
|
|
978
|
+
const lines = ['--- Multi-cloud Cost Comparison ---', '', `Current (${cwd}): $${parseFloat(ic.totalMonthlyCost ?? '0').toFixed(2)}/month`, '', 'To compare across providers, run infracost diff with alternative configs.'];
|
|
979
|
+
return ok(lines.join('\n'));
|
|
980
|
+
} catch { return ok('infracost not available. Install infracost for cross-provider cost comparison.'); }
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Gap 13: non-Terraform platform cost estimation
|
|
984
|
+
if (input.target === 'kubernetes') {
|
|
985
|
+
const nsFlag = input.namespace ? `-n ${input.namespace}` : '--all-namespaces';
|
|
986
|
+
try {
|
|
987
|
+
const { stdout } = await execAsync(`kubectl get pods ${nsFlag} -o json`, { timeout: 30_000, maxBuffer: 5 * 1024 * 1024 });
|
|
988
|
+
const data = JSON.parse(stdout);
|
|
989
|
+
const pods = data.items ?? [];
|
|
990
|
+
let cpuMillis = 0;
|
|
991
|
+
let memMiB = 0;
|
|
992
|
+
for (const pod of pods) {
|
|
993
|
+
for (const container of (pod.spec?.containers ?? [])) {
|
|
994
|
+
const req = container.resources?.requests ?? {};
|
|
995
|
+
const cpu = req.cpu ?? '0';
|
|
996
|
+
const mem = req.memory ?? '0';
|
|
997
|
+
cpuMillis += cpu.endsWith('m') ? parseInt(cpu) : parseInt(cpu) * 1000;
|
|
998
|
+
memMiB += mem.endsWith('Mi') ? parseInt(mem) : mem.endsWith('Gi') ? parseInt(mem) * 1024 : 0;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const cpuCost = (cpuMillis / 1000) * 0.048 * 730; // ~$0.048/vCPU-hour * 730h/month
|
|
1002
|
+
const memCost = (memMiB / 1024) * 0.006 * 730; // ~$0.006/GB-hour * 730h/month
|
|
1003
|
+
return ok([
|
|
1004
|
+
`Kubernetes Cost Estimate (${input.namespace ?? 'all namespaces'}):`,
|
|
1005
|
+
` Pods: ${pods.length}`,
|
|
1006
|
+
` CPU requests: ${cpuMillis}m = ${(cpuMillis / 1000).toFixed(2)} vCPU`,
|
|
1007
|
+
` Memory requests: ${memMiB} MiB`,
|
|
1008
|
+
` Estimated monthly cost: $${(cpuCost + memCost).toFixed(2)}/month`,
|
|
1009
|
+
` (CPU: $${cpuCost.toFixed(2)} + Memory: $${memCost.toFixed(2)})`,
|
|
1010
|
+
' Note: Actual cost depends on node type, region, and spot pricing.',
|
|
1011
|
+
].join('\n'));
|
|
1012
|
+
} catch (error) { return err(`Kubernetes cost estimate failed: ${errorMessage(error)}`); }
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (input.target === 'ecs') {
|
|
1016
|
+
try {
|
|
1017
|
+
const taskFamily = input.workdir ?? 'all';
|
|
1018
|
+
const cmd = taskFamily === 'all'
|
|
1019
|
+
? 'aws ecs list-task-definitions --output json'
|
|
1020
|
+
: `aws ecs describe-task-definition --task-definition ${taskFamily} --output json`;
|
|
1021
|
+
const { stdout } = await execAsync(cmd, { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
|
1022
|
+
return ok(`ECS Task Definition Info:\n${stdout.slice(0, 5000)}\n\nNote: Use AWS Pricing Calculator for exact Fargate costs based on vCPU and memory.`);
|
|
1023
|
+
} catch (error) { return err(`ECS cost estimate failed: ${errorMessage(error)}`); }
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (input.target === 'lambda') {
|
|
1027
|
+
const fn = input.function_name ?? input.workdir;
|
|
1028
|
+
if (!fn) return err('function_name required for Lambda cost estimation');
|
|
1029
|
+
try {
|
|
1030
|
+
const { stdout } = await execAsync(`aws lambda get-function-configuration --function-name ${fn} --output json`, { timeout: 15_000 });
|
|
1031
|
+
const cfg = JSON.parse(stdout);
|
|
1032
|
+
const memMB = cfg.MemorySize ?? 128;
|
|
1033
|
+
const timeout = cfg.Timeout ?? 3;
|
|
1034
|
+
return ok([
|
|
1035
|
+
`Lambda Cost Estimate: ${fn}`,
|
|
1036
|
+
` Memory: ${memMB} MB`,
|
|
1037
|
+
` Timeout: ${timeout}s`,
|
|
1038
|
+
` Cost per 1M invocations (${memMB}MB, avg ${timeout}s): $${((memMB / 1024) * timeout * 0.0000166667 * 1_000_000).toFixed(2)}`,
|
|
1039
|
+
' Free tier: 1M requests + 400,000 GB-seconds/month',
|
|
1040
|
+
' Note: Actual cost depends on invocation count and average duration.',
|
|
1041
|
+
].join('\n'));
|
|
1042
|
+
} catch (error) { return err(`Lambda cost estimate failed: ${errorMessage(error)}`); }
|
|
1043
|
+
}
|
|
1044
|
+
|
|
302
1045
|
if (!input.plan_file && !input.workdir) {
|
|
303
1046
|
return err('Either plan_file or workdir must be provided.');
|
|
304
1047
|
}
|
|
@@ -306,6 +1049,31 @@ export const costEstimateTool: ToolDefinition = {
|
|
|
306
1049
|
const cwd = input.workdir ?? '.';
|
|
307
1050
|
const planArg = input.plan_file ?? '';
|
|
308
1051
|
|
|
1052
|
+
// Try infracost first (real dollar amounts)
|
|
1053
|
+
try {
|
|
1054
|
+
const targetFlag = planArg ? `--path ${planArg}` : `--path ${cwd}`;
|
|
1055
|
+
const { stdout: icOut } = await execAsync(
|
|
1056
|
+
`infracost breakdown ${targetFlag} --format json`,
|
|
1057
|
+
{ timeout: 60_000, maxBuffer: 5 * 1024 * 1024 }
|
|
1058
|
+
);
|
|
1059
|
+
const ic = JSON.parse(icOut);
|
|
1060
|
+
const totalMonthly = parseFloat(ic.totalMonthlyCost ?? '0').toFixed(2);
|
|
1061
|
+
const diffMonthly = parseFloat(ic.diffTotalMonthlyCost ?? '0');
|
|
1062
|
+
const lines = [
|
|
1063
|
+
'--- Cost Estimate (Infracost) ---',
|
|
1064
|
+
`Monthly total: $${totalMonthly}`,
|
|
1065
|
+
diffMonthly !== 0 ? `Monthly change: ${diffMonthly > 0 ? '+' : ''}$${diffMonthly.toFixed(2)}` : null,
|
|
1066
|
+
'',
|
|
1067
|
+
'By resource:',
|
|
1068
|
+
...(ic.projects?.[0]?.resources ?? []).slice(0, 20).map((r: { name: string; monthlyCost?: string }) =>
|
|
1069
|
+
` ${r.name}: $${parseFloat(r.monthlyCost ?? '0').toFixed(2)}/month`
|
|
1070
|
+
),
|
|
1071
|
+
].filter(Boolean);
|
|
1072
|
+
return ok(lines.join('\n'));
|
|
1073
|
+
} catch {
|
|
1074
|
+
// infracost not installed or failed — fall through to resource count
|
|
1075
|
+
}
|
|
1076
|
+
|
|
309
1077
|
// Attempt to extract resource information from a Terraform plan.
|
|
310
1078
|
const showCommand = planArg
|
|
311
1079
|
? `terraform show -json ${planArg}`
|
|
@@ -335,14 +1103,39 @@ export const costEstimateTool: ToolDefinition = {
|
|
|
335
1103
|
);
|
|
336
1104
|
}
|
|
337
1105
|
|
|
1106
|
+
// Built-in pricing lookup for common resource types
|
|
1107
|
+
const RESOURCE_PRICES: Record<string, number> = {
|
|
1108
|
+
'aws_instance': 30, 'aws_db_instance': 50, 'aws_s3_bucket': 5,
|
|
1109
|
+
'aws_nat_gateway': 32, 'aws_lb': 25, 'aws_alb': 25,
|
|
1110
|
+
'aws_eks_cluster': 73, 'aws_elasticache_cluster': 25,
|
|
1111
|
+
'aws_rds_cluster': 50, 'aws_lambda_function': 2,
|
|
1112
|
+
'aws_cloudfront_distribution': 10, 'aws_ecs_cluster': 30,
|
|
1113
|
+
'google_compute_instance': 30, 'google_container_cluster': 73,
|
|
1114
|
+
'google_sql_database_instance': 50, 'google_storage_bucket': 5,
|
|
1115
|
+
'azurerm_virtual_machine': 30, 'azurerm_kubernetes_cluster': 73,
|
|
1116
|
+
'azurerm_sql_database': 50, 'azurerm_storage_account': 5,
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
let estimatedMonthly = 0;
|
|
1120
|
+
const priceLines: string[] = [];
|
|
1121
|
+
for (const rt of resourceTypes) {
|
|
1122
|
+
const price = RESOURCE_PRICES[rt] ?? 5; // default $5 for unknown
|
|
1123
|
+
estimatedMonthly += price;
|
|
1124
|
+
priceLines.push(` ${rt}: ~$${price}/month`);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
338
1127
|
const lines = [
|
|
339
|
-
'--- Cost Estimate (
|
|
1128
|
+
'--- Cost Estimate (Built-in Pricing Tables) ---',
|
|
340
1129
|
'',
|
|
341
1130
|
`Total resources: ${resourceCount}`,
|
|
342
|
-
`
|
|
1131
|
+
`Estimated monthly cost: ~$${estimatedMonthly}/month`,
|
|
1132
|
+
`Estimated annual cost: ~$${estimatedMonthly * 12}/year`,
|
|
343
1133
|
'',
|
|
344
|
-
'
|
|
345
|
-
|
|
1134
|
+
'Resource estimates:',
|
|
1135
|
+
...priceLines.slice(0, 20),
|
|
1136
|
+
'',
|
|
1137
|
+
'Note: For accurate cost estimates install Infracost (infracost.io) or use the AWS/GCP/Azure pricing calculators.',
|
|
1138
|
+
'Built-in prices are approximate 2025 on-demand rates for us-east-1.',
|
|
346
1139
|
];
|
|
347
1140
|
|
|
348
1141
|
return ok(lines.join('\n'));
|
|
@@ -405,29 +1198,119 @@ export const driftDetectTool: ToolDefinition = {
|
|
|
405
1198
|
}
|
|
406
1199
|
|
|
407
1200
|
case 'kubernetes': {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
1201
|
+
const results: string[] = [];
|
|
1202
|
+
|
|
1203
|
+
// Step 1: kubectl diff for locally-tracked manifests
|
|
1204
|
+
try {
|
|
1205
|
+
const { stdout: diffOut } = await execAsync(`kubectl diff -f ${input.workdir} 2>&1 || true`, {
|
|
1206
|
+
timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
|
|
1207
|
+
});
|
|
1208
|
+
if (diffOut.trim()) {
|
|
1209
|
+
results.push('## Tracked Resource Drift (kubectl diff):\n' + diffOut);
|
|
1210
|
+
}
|
|
1211
|
+
} catch { /* ignore */ }
|
|
1212
|
+
|
|
1213
|
+
// Step 2: Fetch live cluster resources to find untracked items
|
|
1214
|
+
const clusterResources: Record<string, Set<string>> = {};
|
|
1215
|
+
try {
|
|
1216
|
+
const { stdout: clusterJson } = await execAsync(
|
|
1217
|
+
'kubectl get all,configmap,ingress,pvc -A -o json 2>/dev/null',
|
|
1218
|
+
{ timeout: 60_000, maxBuffer: 20 * 1024 * 1024 }
|
|
1219
|
+
);
|
|
1220
|
+
const clusterData = JSON.parse(clusterJson);
|
|
1221
|
+
for (const item of (clusterData.items ?? [])) {
|
|
1222
|
+
const kind: string = item.kind ?? 'Unknown';
|
|
1223
|
+
if (!clusterResources[kind]) clusterResources[kind] = new Set();
|
|
1224
|
+
clusterResources[kind].add(`${item.metadata?.namespace ?? 'default'}/${item.metadata?.name}`);
|
|
1225
|
+
}
|
|
1226
|
+
} catch { /* ignore kubectl errors */ }
|
|
1227
|
+
|
|
1228
|
+
// Step 3: Parse local YAML files
|
|
1229
|
+
const localResources: Set<string> = new Set();
|
|
1230
|
+
try {
|
|
1231
|
+
const { readdirSync, readFileSync } = await import('node:fs');
|
|
1232
|
+
const { join: joinPath } = await import('node:path');
|
|
1233
|
+
// Simple YAML scanner for kind/name
|
|
1234
|
+
const scanDir = (dir: string): void => {
|
|
1235
|
+
try {
|
|
1236
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1237
|
+
const full = joinPath(dir, entry.name);
|
|
1238
|
+
if (entry.isDirectory()) scanDir(full);
|
|
1239
|
+
else if (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml')) {
|
|
1240
|
+
const fileContent = readFileSync(full, 'utf-8');
|
|
1241
|
+
const kindMatch = fileContent.match(/^kind:\s*(\S+)/m);
|
|
1242
|
+
const nsMatch = fileContent.match(/^\s*namespace:\s*(\S+)/m);
|
|
1243
|
+
const nameMatch = fileContent.match(/^\s*name:\s*(\S+)/m);
|
|
1244
|
+
if (kindMatch && nameMatch) {
|
|
1245
|
+
const ns = nsMatch?.[1] ?? 'default';
|
|
1246
|
+
localResources.add(`${kindMatch[1]}/${ns}/${nameMatch[1]}`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
} catch { /* ignore */ }
|
|
1251
|
+
};
|
|
1252
|
+
scanDir(input.workdir);
|
|
1253
|
+
} catch { /* ignore */ }
|
|
1254
|
+
|
|
1255
|
+
// Step 4: Find cluster resources not in local files
|
|
1256
|
+
const untracked: string[] = [];
|
|
1257
|
+
for (const [kind, names] of Object.entries(clusterResources)) {
|
|
1258
|
+
for (const ns_name of names) {
|
|
1259
|
+
const key = `${kind}/${ns_name}`;
|
|
1260
|
+
if (!localResources.has(key)) {
|
|
1261
|
+
// Skip system resources
|
|
1262
|
+
const parts = ns_name.split('/');
|
|
1263
|
+
const ns = parts[0];
|
|
1264
|
+
const name = parts[1];
|
|
1265
|
+
if (!['kube-system', 'kube-public', 'kube-node-lease'].includes(ns ?? '') &&
|
|
1266
|
+
!name?.startsWith('kube-') && !name?.startsWith('system:')) {
|
|
1267
|
+
untracked.push(key);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (untracked.length > 0) {
|
|
1274
|
+
results.push(`## Untracked Cluster Resources (${untracked.length} total):\n` +
|
|
1275
|
+
untracked.slice(0, 100).map(r => ` - ${r}`).join('\n') +
|
|
1276
|
+
(untracked.length > 100 ? `\n ... and ${untracked.length - 100} more` : ''));
|
|
1277
|
+
}
|
|
414
1278
|
|
|
415
|
-
if (
|
|
1279
|
+
if (results.length === 0) {
|
|
416
1280
|
return ok('No drift detected in Kubernetes resources.');
|
|
417
1281
|
}
|
|
418
|
-
return ok(`DRIFT DETECTED\n\n${
|
|
1282
|
+
return ok(`DRIFT DETECTED\n\n${results.join('\n\n')}`);
|
|
419
1283
|
}
|
|
420
1284
|
|
|
421
1285
|
case 'helm': {
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1286
|
+
// Try helm-diff plugin first for real drift detection
|
|
1287
|
+
try {
|
|
1288
|
+
const release = (input as { release?: string }).release ?? '';
|
|
1289
|
+
const diffCmd = release
|
|
1290
|
+
? `helm diff upgrade ${release} . --allow-unreleased 2>&1`
|
|
1291
|
+
: `helm list -A --output json`;
|
|
1292
|
+
const { stdout } = await execAsync(diffCmd, { timeout: 60_000, maxBuffer: 5 * 1024 * 1024 });
|
|
1293
|
+
if (!stdout.trim() || stdout.trim() === '[]') {
|
|
1294
|
+
return ok('No drift detected in Helm releases.');
|
|
1295
|
+
}
|
|
1296
|
+
return ok(`Helm drift:\n\n${stdout}`);
|
|
1297
|
+
} catch {
|
|
1298
|
+
// helm-diff not installed — list releases with install hint
|
|
1299
|
+
try {
|
|
1300
|
+
const { stdout } = await execAsync('helm list -A --output json', { timeout: 30_000 });
|
|
1301
|
+
const releases: Array<{ name: string; namespace: string; status: string; chart: string; updated: string }> = JSON.parse(stdout || '[]');
|
|
1302
|
+
if (releases.length === 0) return ok('No Helm releases found.');
|
|
1303
|
+
const lines = releases.map(r =>
|
|
1304
|
+
` ${r.name} (${r.namespace}): ${r.status} — ${r.chart}, updated ${r.updated}`
|
|
1305
|
+
);
|
|
1306
|
+
return ok(
|
|
1307
|
+
`Helm releases:\n${lines.join('\n')}\n\n` +
|
|
1308
|
+
`Note: Install helm-diff for detailed drift: helm plugin install https://github.com/databus23/helm-diff`
|
|
1309
|
+
);
|
|
1310
|
+
} catch (e2) {
|
|
1311
|
+
return err(`Helm drift detection failed: ${errorMessage(e2)}`);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
431
1314
|
}
|
|
432
1315
|
}
|
|
433
1316
|
} catch (error: unknown) {
|
|
@@ -498,89 +1381,335 @@ export const deployPreviewTool: ToolDefinition = {
|
|
|
498
1381
|
};
|
|
499
1382
|
|
|
500
1383
|
// ---------------------------------------------------------------------------
|
|
501
|
-
//
|
|
1384
|
+
// 7b. terraform_plan_analyze
|
|
502
1385
|
// ---------------------------------------------------------------------------
|
|
503
1386
|
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
.describe('The git sub-command to run'),
|
|
508
|
-
args: z.string().optional().describe('Additional CLI arguments'),
|
|
1387
|
+
const terraformPlanAnalyzeSchema = z.object({
|
|
1388
|
+
plan_file: z.string().optional().describe('Path to a saved .tfplan binary or .json plan file'),
|
|
1389
|
+
workdir: z.string().optional().describe('Working directory — runs terraform show -json on the current state'),
|
|
509
1390
|
});
|
|
510
1391
|
|
|
511
|
-
export const
|
|
512
|
-
name: '
|
|
513
|
-
description:
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
permissionTier: 'ask_once',
|
|
1392
|
+
export const terraformPlanAnalyzeTool: ToolDefinition = {
|
|
1393
|
+
name: 'terraform_plan_analyze',
|
|
1394
|
+
description: 'Analyze a Terraform plan file or working directory state. Returns a structured summary of resources to add, change, and destroy with risk assessment.',
|
|
1395
|
+
inputSchema: terraformPlanAnalyzeSchema,
|
|
1396
|
+
permissionTier: 'auto_allow',
|
|
517
1397
|
category: 'devops',
|
|
518
|
-
isDestructive: true,
|
|
519
1398
|
|
|
520
1399
|
async execute(raw: unknown): Promise<ToolResult> {
|
|
521
1400
|
try {
|
|
522
|
-
const input =
|
|
523
|
-
|
|
524
|
-
const parts: string[] = ['git', input.action];
|
|
1401
|
+
const input = terraformPlanAnalyzeSchema.parse(raw);
|
|
525
1402
|
|
|
526
|
-
if (input.
|
|
527
|
-
|
|
1403
|
+
if (!input.plan_file && !input.workdir) {
|
|
1404
|
+
return err('Either plan_file or workdir must be provided.');
|
|
528
1405
|
}
|
|
529
1406
|
|
|
530
|
-
const
|
|
531
|
-
|
|
1407
|
+
const showCmd = input.plan_file
|
|
1408
|
+
? `terraform show -json ${input.plan_file}`
|
|
1409
|
+
: `terraform -chdir=${input.workdir} show -json`;
|
|
1410
|
+
|
|
1411
|
+
const { stdout } = await execAsync(showCmd, {
|
|
532
1412
|
timeout: 60_000,
|
|
533
1413
|
maxBuffer: 10 * 1024 * 1024,
|
|
534
1414
|
});
|
|
535
1415
|
|
|
536
|
-
|
|
537
|
-
|
|
1416
|
+
let plan: Record<string, unknown>;
|
|
1417
|
+
try {
|
|
1418
|
+
plan = JSON.parse(stdout);
|
|
1419
|
+
} catch {
|
|
1420
|
+
return err('Failed to parse terraform show output as JSON. Make sure the plan file is valid.');
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const changes = (plan.resource_changes as Array<{
|
|
1424
|
+
address: string;
|
|
1425
|
+
type: string;
|
|
1426
|
+
name: string;
|
|
1427
|
+
change: { actions: string[] };
|
|
1428
|
+
}>) ?? [];
|
|
1429
|
+
|
|
1430
|
+
const toAdd = changes.filter(r => r.change?.actions?.includes('create'));
|
|
1431
|
+
const toChange = changes.filter(r => r.change?.actions?.includes('update'));
|
|
1432
|
+
const toDestroy = changes.filter(r => r.change?.actions?.includes('delete'));
|
|
1433
|
+
const toReplace = changes.filter(
|
|
1434
|
+
r =>
|
|
1435
|
+
r.change?.actions?.includes('create') && r.change?.actions?.includes('delete')
|
|
1436
|
+
);
|
|
1437
|
+
|
|
1438
|
+
// Risk assessment
|
|
1439
|
+
const highRiskTypes = ['aws_instance', 'aws_db_instance', 'aws_rds_cluster', 'google_sql_database_instance', 'azurerm_sql_server', 'aws_eks_cluster'];
|
|
1440
|
+
const highRiskDestroys = toDestroy.filter(r => highRiskTypes.includes(r.type));
|
|
1441
|
+
|
|
1442
|
+
const lines = [
|
|
1443
|
+
'=== Terraform Plan Analysis ===',
|
|
1444
|
+
'',
|
|
1445
|
+
`Resources to CREATE: ${toAdd.length}`,
|
|
1446
|
+
...toAdd.slice(0, 10).map(r => ` + ${r.address}`),
|
|
1447
|
+
toAdd.length > 10 ? ` ... and ${toAdd.length - 10} more` : '',
|
|
1448
|
+
'',
|
|
1449
|
+
`Resources to CHANGE: ${toChange.length}`,
|
|
1450
|
+
...toChange.slice(0, 10).map(r => ` ~ ${r.address}`),
|
|
1451
|
+
toChange.length > 10 ? ` ... and ${toChange.length - 10} more` : '',
|
|
1452
|
+
'',
|
|
1453
|
+
`Resources to DESTROY: ${toDestroy.length}`,
|
|
1454
|
+
...toDestroy.slice(0, 10).map(r => ` - ${r.address}`),
|
|
1455
|
+
toDestroy.length > 10 ? ` ... and ${toDestroy.length - 10} more` : '',
|
|
1456
|
+
'',
|
|
1457
|
+
toReplace.length > 0 ? `Resources to REPLACE (destroy+create): ${toReplace.length}` : '',
|
|
1458
|
+
...toReplace.map(r => ` ± ${r.address}`),
|
|
1459
|
+
'',
|
|
1460
|
+
'=== Risk Assessment ===',
|
|
1461
|
+
toDestroy.length === 0 && toReplace.length === 0
|
|
1462
|
+
? 'LOW RISK: No destructive changes'
|
|
1463
|
+
: toDestroy.length > 0 && highRiskDestroys.length > 0
|
|
1464
|
+
? `HIGH RISK: Destroying ${highRiskDestroys.length} high-risk resource(s): ${highRiskDestroys.map(r => r.address).join(', ')}`
|
|
1465
|
+
: toDestroy.length > 0
|
|
1466
|
+
? `MEDIUM RISK: ${toDestroy.length} resource(s) will be destroyed`
|
|
1467
|
+
: 'LOW RISK: Changes only (no destroys)',
|
|
1468
|
+
].filter(l => l !== '');
|
|
1469
|
+
|
|
1470
|
+
return ok(lines.join('\n'));
|
|
538
1471
|
} catch (error: unknown) {
|
|
539
|
-
return err(`
|
|
1472
|
+
return err(`Terraform plan analysis failed: ${errorMessage(error)}`);
|
|
540
1473
|
}
|
|
541
1474
|
},
|
|
542
1475
|
};
|
|
543
1476
|
|
|
544
1477
|
// ---------------------------------------------------------------------------
|
|
545
|
-
//
|
|
1478
|
+
// 10. kubectl_context
|
|
546
1479
|
// ---------------------------------------------------------------------------
|
|
547
1480
|
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
.
|
|
552
|
-
|
|
553
|
-
.default('general')
|
|
554
|
-
.describe('Subagent specialization to handle the task (default: general)'),
|
|
1481
|
+
const kubectlContextSchema = z.object({
|
|
1482
|
+
action: z
|
|
1483
|
+
.enum(['list', 'current', 'switch', 'namespaces'])
|
|
1484
|
+
.describe('Action: list all contexts, show current context, switch to a context, or list namespaces'),
|
|
1485
|
+
context: z.string().optional().describe('Context name to switch to (required for switch action)'),
|
|
555
1486
|
});
|
|
556
1487
|
|
|
557
|
-
export const
|
|
558
|
-
name: '
|
|
559
|
-
description:
|
|
560
|
-
|
|
561
|
-
inputSchema: taskSchema,
|
|
1488
|
+
export const kubectlContextTool: ToolDefinition = {
|
|
1489
|
+
name: 'kubectl_context',
|
|
1490
|
+
description: 'Manage Kubernetes contexts (kubeconfig). List, inspect, or switch between cluster contexts without running raw kubectl commands.',
|
|
1491
|
+
inputSchema: kubectlContextSchema,
|
|
562
1492
|
permissionTier: 'auto_allow',
|
|
563
1493
|
category: 'devops',
|
|
564
1494
|
|
|
565
1495
|
async execute(raw: unknown): Promise<ToolResult> {
|
|
566
1496
|
try {
|
|
567
|
-
const input =
|
|
1497
|
+
const input = kubectlContextSchema.parse(raw);
|
|
568
1498
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1499
|
+
switch (input.action) {
|
|
1500
|
+
case 'current': {
|
|
1501
|
+
const { stdout } = await execAsync('kubectl config current-context', { timeout: 5000 });
|
|
1502
|
+
const ctx = stdout.trim();
|
|
1503
|
+
// Also get cluster info
|
|
1504
|
+
try {
|
|
1505
|
+
const { stdout: clusterInfo } = await execAsync(
|
|
1506
|
+
`kubectl config get-clusters | grep -v NAME`,
|
|
1507
|
+
{ timeout: 5000 }
|
|
1508
|
+
);
|
|
1509
|
+
return ok(`Current context: ${ctx}\n\nAll clusters:\n${clusterInfo.trim()}`);
|
|
1510
|
+
} catch {
|
|
1511
|
+
return ok(`Current context: ${ctx}`);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
575
1514
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1515
|
+
case 'list': {
|
|
1516
|
+
const { stdout } = await execAsync('kubectl config get-contexts', { timeout: 5000, maxBuffer: 1024 * 1024 });
|
|
1517
|
+
return ok(stdout.trim() || 'No contexts found in kubeconfig.');
|
|
1518
|
+
}
|
|
580
1519
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1520
|
+
case 'switch': {
|
|
1521
|
+
if (!input.context) {
|
|
1522
|
+
return err('context parameter is required for switch action');
|
|
1523
|
+
}
|
|
1524
|
+
const { stdout } = await execAsync(
|
|
1525
|
+
`kubectl config use-context ${input.context}`,
|
|
1526
|
+
{ timeout: 5000 }
|
|
1527
|
+
);
|
|
1528
|
+
return ok(stdout.trim());
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
case 'namespaces': {
|
|
1532
|
+
const { stdout } = await execAsync('kubectl get namespaces -o wide', {
|
|
1533
|
+
timeout: 15_000,
|
|
1534
|
+
maxBuffer: 1024 * 1024,
|
|
1535
|
+
});
|
|
1536
|
+
return ok(stdout.trim());
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
} catch (error: unknown) {
|
|
1540
|
+
return err(`kubectl_context failed: ${errorMessage(error)}`);
|
|
1541
|
+
}
|
|
1542
|
+
},
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
// ---------------------------------------------------------------------------
|
|
1546
|
+
// 11. helm_values
|
|
1547
|
+
// ---------------------------------------------------------------------------
|
|
1548
|
+
|
|
1549
|
+
const helmValuesSchema = z.object({
|
|
1550
|
+
action: z
|
|
1551
|
+
.enum(['show-defaults', 'get-release', 'diff-values'])
|
|
1552
|
+
.describe('Action: show default chart values, get values for a deployed release, or diff values between releases'),
|
|
1553
|
+
chart: z.string().optional().describe('Chart reference (e.g., bitnami/nginx) for show-defaults'),
|
|
1554
|
+
release: z.string().optional().describe('Release name for get-release or diff-values'),
|
|
1555
|
+
namespace: z.string().optional().describe('Kubernetes namespace for the release'),
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
export const helmValuesTool: ToolDefinition = {
|
|
1559
|
+
name: 'helm_values',
|
|
1560
|
+
description: 'Inspect Helm chart values. Show default values for a chart, get values for a deployed release, or diff two revisions.',
|
|
1561
|
+
inputSchema: helmValuesSchema,
|
|
1562
|
+
permissionTier: 'auto_allow',
|
|
1563
|
+
category: 'devops',
|
|
1564
|
+
|
|
1565
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
1566
|
+
try {
|
|
1567
|
+
const input = helmValuesSchema.parse(raw);
|
|
1568
|
+
|
|
1569
|
+
switch (input.action) {
|
|
1570
|
+
case 'show-defaults': {
|
|
1571
|
+
if (!input.chart) {
|
|
1572
|
+
return err('chart parameter is required for show-defaults action');
|
|
1573
|
+
}
|
|
1574
|
+
const { stdout } = await execAsync(`helm show values ${input.chart}`, {
|
|
1575
|
+
timeout: 60_000,
|
|
1576
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
1577
|
+
});
|
|
1578
|
+
return ok(stdout.trim() || '(no default values)');
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
case 'get-release': {
|
|
1582
|
+
if (!input.release) {
|
|
1583
|
+
return err('release parameter is required for get-release action');
|
|
1584
|
+
}
|
|
1585
|
+
const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
|
|
1586
|
+
const { stdout } = await execAsync(
|
|
1587
|
+
`helm get values ${input.release} ${nsFlag} --all`,
|
|
1588
|
+
{ timeout: 30_000, maxBuffer: 5 * 1024 * 1024 }
|
|
1589
|
+
);
|
|
1590
|
+
return ok(stdout.trim() || '(no custom values — using defaults)');
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
case 'diff-values': {
|
|
1594
|
+
if (!input.release) {
|
|
1595
|
+
return err('release parameter is required for diff-values action');
|
|
1596
|
+
}
|
|
1597
|
+
const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
|
|
1598
|
+
// Get history
|
|
1599
|
+
const { stdout: histOut } = await execAsync(
|
|
1600
|
+
`helm history ${input.release} ${nsFlag} --output json`,
|
|
1601
|
+
{ timeout: 30_000, maxBuffer: 1024 * 1024 }
|
|
1602
|
+
);
|
|
1603
|
+
const history = JSON.parse(histOut || '[]') as Array<{ revision: number }>;
|
|
1604
|
+
if (history.length < 2) {
|
|
1605
|
+
return ok(`Only ${history.length} revision(s) found. Need at least 2 to diff.`);
|
|
1606
|
+
}
|
|
1607
|
+
const latest = history[history.length - 1].revision;
|
|
1608
|
+
const previous = history[history.length - 2].revision;
|
|
1609
|
+
const [latestVals, prevVals] = await Promise.all([
|
|
1610
|
+
execAsync(`helm get values ${input.release} ${nsFlag} --revision ${latest}`, { timeout: 30_000 }),
|
|
1611
|
+
execAsync(`helm get values ${input.release} ${nsFlag} --revision ${previous}`, { timeout: 30_000 }),
|
|
1612
|
+
]);
|
|
1613
|
+
if (latestVals.stdout === prevVals.stdout) {
|
|
1614
|
+
return ok(`No value changes between revision ${previous} and ${latest}.`);
|
|
1615
|
+
}
|
|
1616
|
+
return ok(
|
|
1617
|
+
`Values diff (revision ${previous} → ${latest}):\n\n` +
|
|
1618
|
+
`=== Revision ${previous} ===\n${prevVals.stdout.trim()}\n\n` +
|
|
1619
|
+
`=== Revision ${latest} ===\n${latestVals.stdout.trim()}`
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
} catch (error: unknown) {
|
|
1624
|
+
return err(`helm_values failed: ${errorMessage(error)}`);
|
|
1625
|
+
}
|
|
1626
|
+
},
|
|
1627
|
+
};
|
|
1628
|
+
|
|
1629
|
+
// ---------------------------------------------------------------------------
|
|
1630
|
+
// 8. git
|
|
1631
|
+
// ---------------------------------------------------------------------------
|
|
1632
|
+
|
|
1633
|
+
const gitSchema = z.object({
|
|
1634
|
+
action: z
|
|
1635
|
+
.enum(['status', 'add', 'commit', 'push', 'pull', 'branch', 'checkout', 'diff', 'log'])
|
|
1636
|
+
.describe('The git sub-command to run'),
|
|
1637
|
+
args: z.string().optional().describe('Additional CLI arguments'),
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
export const gitTool: ToolDefinition = {
|
|
1641
|
+
name: 'git',
|
|
1642
|
+
description:
|
|
1643
|
+
'Execute git operations. Supports status, add, commit, push, pull, branch, checkout, diff, and log.',
|
|
1644
|
+
inputSchema: gitSchema,
|
|
1645
|
+
permissionTier: 'ask_once',
|
|
1646
|
+
category: 'devops',
|
|
1647
|
+
isDestructive: true,
|
|
1648
|
+
|
|
1649
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
1650
|
+
try {
|
|
1651
|
+
const input = gitSchema.parse(raw);
|
|
1652
|
+
|
|
1653
|
+
const parts: string[] = ['git', input.action];
|
|
1654
|
+
|
|
1655
|
+
if (input.args) {
|
|
1656
|
+
parts.push(input.args);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const command = parts.join(' ');
|
|
1660
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
1661
|
+
timeout: 60_000,
|
|
1662
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
1666
|
+
return ok(combined || '(no output)');
|
|
1667
|
+
} catch (error: unknown) {
|
|
1668
|
+
return err(`git command failed: ${errorMessage(error)}`);
|
|
1669
|
+
}
|
|
1670
|
+
},
|
|
1671
|
+
};
|
|
1672
|
+
|
|
1673
|
+
// ---------------------------------------------------------------------------
|
|
1674
|
+
// 9. task (subagent)
|
|
1675
|
+
// ---------------------------------------------------------------------------
|
|
1676
|
+
|
|
1677
|
+
const taskSchema = z.object({
|
|
1678
|
+
prompt: z.string().describe('The task for the subagent to perform'),
|
|
1679
|
+
agent: z
|
|
1680
|
+
.enum(['explore', 'infra', 'security', 'cost', 'general'])
|
|
1681
|
+
.optional()
|
|
1682
|
+
.default('general')
|
|
1683
|
+
.describe('Subagent specialization to handle the task (default: general)'),
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
export const taskTool: ToolDefinition = {
|
|
1687
|
+
name: 'task',
|
|
1688
|
+
description:
|
|
1689
|
+
'Spawn a subagent to handle a specific task. The subagent runs with its own isolated context and returns results. Use for parallelizable research, code exploration, security audits, cost analysis, or infrastructure checks.',
|
|
1690
|
+
inputSchema: taskSchema,
|
|
1691
|
+
permissionTier: 'auto_allow',
|
|
1692
|
+
category: 'devops',
|
|
1693
|
+
|
|
1694
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
1695
|
+
try {
|
|
1696
|
+
const input = taskSchema.parse(raw);
|
|
1697
|
+
|
|
1698
|
+
// Get the LLM router from the app context
|
|
1699
|
+
const { getAppContext } = await import('../../app');
|
|
1700
|
+
const ctx = getAppContext();
|
|
1701
|
+
if (!ctx) {
|
|
1702
|
+
return err('App not initialised. Cannot spawn subagent.');
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// Create and run the appropriate subagent
|
|
1706
|
+
const { createSubagent } = await import('../../agent/subagents/index');
|
|
1707
|
+
const subagent = createSubagent(input.agent as any);
|
|
1708
|
+
const result = await subagent.run(input.prompt, ctx.router);
|
|
1709
|
+
|
|
1710
|
+
const header = [
|
|
1711
|
+
`[Subagent: ${input.agent}]`,
|
|
1712
|
+
`Turns: ${result.turns} | Tokens: ${result.totalTokens}`,
|
|
584
1713
|
result.interrupted ? '(interrupted)' : '',
|
|
585
1714
|
'---',
|
|
586
1715
|
]
|
|
@@ -594,11 +1723,1753 @@ export const taskTool: ToolDefinition = {
|
|
|
594
1723
|
},
|
|
595
1724
|
};
|
|
596
1725
|
|
|
1726
|
+
|
|
1727
|
+
// ---------------------------------------------------------------------------
|
|
1728
|
+
// 13. docker
|
|
1729
|
+
// ---------------------------------------------------------------------------
|
|
1730
|
+
|
|
1731
|
+
const dockerSchema = z.object({
|
|
1732
|
+
action: z.enum(['build','push','pull','run','ps','stop','rm','images',
|
|
1733
|
+
'compose-up','compose-down','logs','exec','inspect','prune'])
|
|
1734
|
+
.describe('Docker action to perform'),
|
|
1735
|
+
image: z.string().optional().describe('Image name (with optional tag)'),
|
|
1736
|
+
container: z.string().optional().describe('Container name or ID'),
|
|
1737
|
+
tag: z.string().optional().describe('Image tag (default: latest)'),
|
|
1738
|
+
file: z.string().optional().describe('Dockerfile path'),
|
|
1739
|
+
args: z.string().optional().describe('Additional arguments'),
|
|
1740
|
+
workdir: z.string().optional().describe('Working directory for build/compose'),
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
export const dockerTool: ToolDefinition = {
|
|
1744
|
+
name: 'docker',
|
|
1745
|
+
description: 'Execute Docker operations: build images, manage containers, run compose, view logs, inspect, and prune.',
|
|
1746
|
+
inputSchema: dockerSchema,
|
|
1747
|
+
permissionTier: 'always_ask',
|
|
1748
|
+
category: 'devops',
|
|
1749
|
+
isDestructive: true,
|
|
1750
|
+
|
|
1751
|
+
async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
|
|
1752
|
+
try {
|
|
1753
|
+
const input = dockerSchema.parse(raw);
|
|
1754
|
+
|
|
1755
|
+
// Safety: block --privileged / --network=host in run args
|
|
1756
|
+
if (input.action === 'run' && input.args) {
|
|
1757
|
+
if (input.args.includes('--privileged') || input.args.includes('--network=host')) {
|
|
1758
|
+
return err(
|
|
1759
|
+
'SAFETY CHECK: --privileged and --network=host flags are blocked by default.\n' +
|
|
1760
|
+
'These flags grant significant host access. Remove them or confirm intent explicitly.'
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
let command: string;
|
|
1766
|
+
const wdir = input.workdir ?? '.';
|
|
1767
|
+
|
|
1768
|
+
switch (input.action) {
|
|
1769
|
+
case 'build': {
|
|
1770
|
+
const tag = input.tag ? `:${input.tag}` : ':latest';
|
|
1771
|
+
const imageRef = input.image ? `${input.image}${tag}` : 'local-build:latest';
|
|
1772
|
+
const fileFlag = input.file ? `-f ${input.file}` : '';
|
|
1773
|
+
command = `docker build -t ${imageRef} ${fileFlag} ${wdir}`.trim().replace(/\s+/g, ' ');
|
|
1774
|
+
break;
|
|
1775
|
+
}
|
|
1776
|
+
case 'push':
|
|
1777
|
+
command = `docker push ${input.image}${input.tag ? `:${input.tag}` : ''}`;
|
|
1778
|
+
break;
|
|
1779
|
+
case 'pull':
|
|
1780
|
+
command = `docker pull ${input.image}${input.tag ? `:${input.tag}` : ''}`;
|
|
1781
|
+
break;
|
|
1782
|
+
case 'run': {
|
|
1783
|
+
const imageRef = `${input.image ?? 'unknown'}${input.tag ? `:${input.tag}` : ''}`;
|
|
1784
|
+
command = `docker run ${input.args ?? ''} ${imageRef}`.trim();
|
|
1785
|
+
break;
|
|
1786
|
+
}
|
|
1787
|
+
case 'ps':
|
|
1788
|
+
command = `docker ps ${input.args ?? ''}`.trim();
|
|
1789
|
+
break;
|
|
1790
|
+
case 'stop':
|
|
1791
|
+
command = `docker stop ${input.container ?? ''}`.trim();
|
|
1792
|
+
break;
|
|
1793
|
+
case 'rm':
|
|
1794
|
+
command = `docker rm ${input.container ?? ''} ${input.args ?? ''}`.trim();
|
|
1795
|
+
break;
|
|
1796
|
+
case 'images':
|
|
1797
|
+
command = `docker images ${input.args ?? ''}`.trim();
|
|
1798
|
+
break;
|
|
1799
|
+
case 'compose-up':
|
|
1800
|
+
command = `docker compose -f ${input.file ?? 'docker-compose.yml'} up -d ${input.args ?? ''}`.trim();
|
|
1801
|
+
break;
|
|
1802
|
+
case 'compose-down':
|
|
1803
|
+
command = `docker compose -f ${input.file ?? 'docker-compose.yml'} down ${input.args ?? ''}`.trim();
|
|
1804
|
+
break;
|
|
1805
|
+
case 'logs':
|
|
1806
|
+
command = `docker logs ${input.container ?? ''} ${input.args ?? '--tail=100'}`.trim();
|
|
1807
|
+
break;
|
|
1808
|
+
case 'exec':
|
|
1809
|
+
command = `docker exec ${input.container ?? ''} ${input.args ?? '/bin/sh'}`.trim();
|
|
1810
|
+
break;
|
|
1811
|
+
case 'inspect':
|
|
1812
|
+
command = `docker inspect ${input.container ?? input.image ?? ''}`.trim();
|
|
1813
|
+
break;
|
|
1814
|
+
case 'prune':
|
|
1815
|
+
command = `docker system prune -f ${input.args ?? ''}`.trim();
|
|
1816
|
+
break;
|
|
1817
|
+
default:
|
|
1818
|
+
return err(`Unknown docker action: ${input.action}`);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// Override permissionTier for read-only actions
|
|
1822
|
+
const readOnlyActions = ['ps', 'images', 'logs', 'inspect'];
|
|
1823
|
+
if (readOnlyActions.includes(input.action)) {
|
|
1824
|
+
// These are safe — no special gate needed
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// M2: Docker build — use spawnExec with progress filter when ctx?.onProgress available
|
|
1828
|
+
if (input.action === 'build' && ctx?.onProgress) {
|
|
1829
|
+
const filteredProgress = (chunk: string) => {
|
|
1830
|
+
const lines = chunk.split('\n');
|
|
1831
|
+
for (const line of lines) {
|
|
1832
|
+
const trimmed = line.trim();
|
|
1833
|
+
if (!trimmed) continue;
|
|
1834
|
+
// Keep: Step N/M, Using cache, Successfully built, error, FROM/RUN/COPY step info
|
|
1835
|
+
if (/^Step\s+\d+\/\d+/i.test(trimmed) ||
|
|
1836
|
+
/---> Using cache/i.test(trimmed) ||
|
|
1837
|
+
/Successfully built/i.test(trimmed) ||
|
|
1838
|
+
/Successfully tagged/i.test(trimmed) ||
|
|
1839
|
+
/error/i.test(trimmed) ||
|
|
1840
|
+
/warning/i.test(trimmed)) {
|
|
1841
|
+
ctx.onProgress!(line + '\n');
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
};
|
|
1845
|
+
const buildResult = await spawnExec(command, { onChunk: filteredProgress, timeout: ctx?.timeout ?? 300_000 });
|
|
1846
|
+
const combined = [buildResult.stdout, buildResult.stderr].filter(Boolean).join('\n');
|
|
1847
|
+
if (buildResult.exitCode !== 0) return err(`Docker build failed:\n${combined}`);
|
|
1848
|
+
return ok(combined || 'Build complete.');
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
1852
|
+
timeout: 300_000,
|
|
1853
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
1857
|
+
return ok(combined || '(no output)');
|
|
1858
|
+
} catch (error: unknown) {
|
|
1859
|
+
return err(`Docker command failed: ${errorMessage(error)}`);
|
|
1860
|
+
}
|
|
1861
|
+
},
|
|
1862
|
+
};
|
|
1863
|
+
|
|
1864
|
+
// ---------------------------------------------------------------------------
|
|
1865
|
+
// 14. secrets
|
|
1866
|
+
// ---------------------------------------------------------------------------
|
|
1867
|
+
|
|
1868
|
+
const secretsSchema = z.object({
|
|
1869
|
+
action: z.enum(['get','list','put','delete','rotate','versions'])
|
|
1870
|
+
.describe('Action to perform on the secret'),
|
|
1871
|
+
provider: z.enum(['vault','aws','gcp','azure'])
|
|
1872
|
+
.describe('Secrets provider to use'),
|
|
1873
|
+
path: z.string().describe('Secret path, ARN, or name'),
|
|
1874
|
+
value: z.string().optional().describe('Secret value for put action'),
|
|
1875
|
+
version: z.number().optional().describe('Secret version number'),
|
|
1876
|
+
region: z.string().optional().describe('Cloud region'),
|
|
1877
|
+
namespace: z.string().optional().describe('Vault namespace'),
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
export const secretsTool: ToolDefinition = {
|
|
1881
|
+
name: 'secrets',
|
|
1882
|
+
description: 'Manage secrets across Vault, AWS Secrets Manager, GCP Secret Manager, and Azure Key Vault. Secret values are always redacted in output.',
|
|
1883
|
+
inputSchema: secretsSchema,
|
|
1884
|
+
permissionTier: 'always_ask',
|
|
1885
|
+
category: 'devops',
|
|
1886
|
+
isDestructive: true,
|
|
1887
|
+
|
|
1888
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
1889
|
+
try {
|
|
1890
|
+
const input = secretsSchema.parse(raw);
|
|
1891
|
+
|
|
1892
|
+
let command: string;
|
|
1893
|
+
|
|
1894
|
+
switch (input.provider) {
|
|
1895
|
+
case 'vault': {
|
|
1896
|
+
const nsFlag = input.namespace ? `VAULT_NAMESPACE=${input.namespace} ` : '';
|
|
1897
|
+
switch (input.action) {
|
|
1898
|
+
case 'get':
|
|
1899
|
+
command = `${nsFlag}vault kv get -format=json ${input.path}`;
|
|
1900
|
+
break;
|
|
1901
|
+
case 'list':
|
|
1902
|
+
command = `${nsFlag}vault kv list -format=json ${input.path}`;
|
|
1903
|
+
break;
|
|
1904
|
+
case 'put':
|
|
1905
|
+
if (!input.value) return err('value is required for put action');
|
|
1906
|
+
command = `${nsFlag}vault kv put ${input.path} value=${input.value}`;
|
|
1907
|
+
break;
|
|
1908
|
+
case 'delete':
|
|
1909
|
+
command = `${nsFlag}vault kv delete ${input.path}`;
|
|
1910
|
+
break;
|
|
1911
|
+
case 'versions':
|
|
1912
|
+
command = `${nsFlag}vault kv metadata get -format=json ${input.path}`;
|
|
1913
|
+
break;
|
|
1914
|
+
case 'rotate':
|
|
1915
|
+
return err('rotate is not supported for Vault — use put to update the secret value');
|
|
1916
|
+
}
|
|
1917
|
+
break;
|
|
1918
|
+
}
|
|
1919
|
+
case 'aws': {
|
|
1920
|
+
const regionFlag = input.region ? `--region ${input.region}` : '';
|
|
1921
|
+
switch (input.action) {
|
|
1922
|
+
case 'get':
|
|
1923
|
+
command = `aws secretsmanager get-secret-value --secret-id ${input.path} ${regionFlag} --output json`;
|
|
1924
|
+
break;
|
|
1925
|
+
case 'list':
|
|
1926
|
+
command = `aws secretsmanager list-secrets ${regionFlag} --output json`;
|
|
1927
|
+
break;
|
|
1928
|
+
case 'put':
|
|
1929
|
+
if (!input.value) return err('value is required for put action');
|
|
1930
|
+
command = `aws secretsmanager put-secret-value --secret-id ${input.path} --secret-string '${input.value.replace(/'/g, "'\\''")}' ${regionFlag}`;
|
|
1931
|
+
break;
|
|
1932
|
+
case 'delete':
|
|
1933
|
+
command = `aws secretsmanager delete-secret --secret-id ${input.path} ${regionFlag} --force-delete-without-recovery`;
|
|
1934
|
+
break;
|
|
1935
|
+
case 'versions':
|
|
1936
|
+
command = `aws secretsmanager list-secret-version-ids --secret-id ${input.path} ${regionFlag} --output json`;
|
|
1937
|
+
break;
|
|
1938
|
+
case 'rotate':
|
|
1939
|
+
command = `aws secretsmanager rotate-secret --secret-id ${input.path} ${regionFlag}`;
|
|
1940
|
+
break;
|
|
1941
|
+
}
|
|
1942
|
+
break;
|
|
1943
|
+
}
|
|
1944
|
+
case 'gcp': {
|
|
1945
|
+
switch (input.action) {
|
|
1946
|
+
case 'get':
|
|
1947
|
+
command = `gcloud secrets versions access ${input.version ?? 'latest'} --secret=${input.path} --format=json`;
|
|
1948
|
+
break;
|
|
1949
|
+
case 'list':
|
|
1950
|
+
command = `gcloud secrets list --format=json`;
|
|
1951
|
+
break;
|
|
1952
|
+
case 'put':
|
|
1953
|
+
if (!input.value) return err('value is required for put action');
|
|
1954
|
+
command = `echo '${input.value.replace(/'/g, "'\\''")}' | gcloud secrets create ${input.path} --data-file=-`;
|
|
1955
|
+
break;
|
|
1956
|
+
case 'delete':
|
|
1957
|
+
command = `gcloud secrets delete ${input.path} --quiet`;
|
|
1958
|
+
break;
|
|
1959
|
+
case 'versions':
|
|
1960
|
+
command = `gcloud secrets versions list ${input.path} --format=json`;
|
|
1961
|
+
break;
|
|
1962
|
+
case 'rotate':
|
|
1963
|
+
return err('rotate for GCP: create a new version with put action');
|
|
1964
|
+
}
|
|
1965
|
+
break;
|
|
1966
|
+
}
|
|
1967
|
+
case 'azure': {
|
|
1968
|
+
const vaultFlag = input.namespace ? `--vault-name ${input.namespace}` : '';
|
|
1969
|
+
switch (input.action) {
|
|
1970
|
+
case 'get':
|
|
1971
|
+
command = `az keyvault secret show --name ${input.path} ${vaultFlag} --output json`;
|
|
1972
|
+
break;
|
|
1973
|
+
case 'list':
|
|
1974
|
+
command = `az keyvault secret list ${vaultFlag} --output json`;
|
|
1975
|
+
break;
|
|
1976
|
+
case 'put':
|
|
1977
|
+
if (!input.value) return err('value is required for put action');
|
|
1978
|
+
command = `az keyvault secret set --name ${input.path} --value '${input.value.replace(/'/g, "'\\''")}' ${vaultFlag}`;
|
|
1979
|
+
break;
|
|
1980
|
+
case 'delete':
|
|
1981
|
+
command = `az keyvault secret delete --name ${input.path} ${vaultFlag}`;
|
|
1982
|
+
break;
|
|
1983
|
+
case 'versions':
|
|
1984
|
+
command = `az keyvault secret list-versions --name ${input.path} ${vaultFlag} --output json`;
|
|
1985
|
+
break;
|
|
1986
|
+
case 'rotate':
|
|
1987
|
+
return err('rotate for Azure: use put to set a new secret version');
|
|
1988
|
+
}
|
|
1989
|
+
break;
|
|
1990
|
+
}
|
|
1991
|
+
default:
|
|
1992
|
+
return err(`Unknown provider: ${input.provider}`);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const { stdout, stderr } = await execAsync(command!, {
|
|
1996
|
+
timeout: 30_000,
|
|
1997
|
+
maxBuffer: 1 * 1024 * 1024,
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
2001
|
+
|
|
2002
|
+
// CRITICAL: Redact secret values from output
|
|
2003
|
+
let output = combined;
|
|
2004
|
+
if (input.action === 'get') {
|
|
2005
|
+
try {
|
|
2006
|
+
const parsed = JSON.parse(combined);
|
|
2007
|
+
// AWS: redact SecretString
|
|
2008
|
+
if (parsed.SecretString) {
|
|
2009
|
+
parsed.SecretString = '[REDACTED — value retrieved successfully]';
|
|
2010
|
+
}
|
|
2011
|
+
// Vault: redact data fields
|
|
2012
|
+
if (parsed.data?.data) {
|
|
2013
|
+
for (const key of Object.keys(parsed.data.data)) {
|
|
2014
|
+
parsed.data.data[key] = '[REDACTED]';
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
// GCP: redact payload
|
|
2018
|
+
if (parsed.payload?.data) {
|
|
2019
|
+
parsed.payload.data = '[REDACTED — value retrieved successfully]';
|
|
2020
|
+
}
|
|
2021
|
+
// Azure: redact value
|
|
2022
|
+
if (parsed.value) {
|
|
2023
|
+
parsed.value = '[REDACTED — value retrieved successfully]';
|
|
2024
|
+
}
|
|
2025
|
+
output = JSON.stringify(parsed, null, 2);
|
|
2026
|
+
} catch {
|
|
2027
|
+
// Not JSON or parse failed — redact with regex
|
|
2028
|
+
output = combined.replace(/"(SecretString|value|data)"\s*:\s*"[^"]*"/g, '"$1": "[REDACTED]"');
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
return ok(output || '(success)');
|
|
2033
|
+
} catch (error: unknown) {
|
|
2034
|
+
return err(`Secrets operation failed: ${errorMessage(error)}`);
|
|
2035
|
+
}
|
|
2036
|
+
},
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
// ---------------------------------------------------------------------------
|
|
2040
|
+
// 15. cicd (CI/CD pipeline management)
|
|
2041
|
+
// ---------------------------------------------------------------------------
|
|
2042
|
+
|
|
2043
|
+
const cicdSchema = z.object({
|
|
2044
|
+
action: z.enum(['list','get','trigger','retry','cancel','logs','status','artifacts'])
|
|
2045
|
+
.describe('CI/CD action to perform'),
|
|
2046
|
+
provider: z.enum(['github','gitlab','circleci'])
|
|
2047
|
+
.describe('CI/CD provider'),
|
|
2048
|
+
repo: z.string().optional().describe('Repository in owner/repo format'),
|
|
2049
|
+
workflow: z.string().optional().describe('Workflow file or pipeline ID'),
|
|
2050
|
+
branch: z.string().optional().describe('Branch name'),
|
|
2051
|
+
run_id: z.string().optional().describe('Run/pipeline ID'),
|
|
2052
|
+
project_slug: z.string().optional().describe('CircleCI project slug: org-type/org/repo'),
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
export const cicdTool: ToolDefinition = {
|
|
2056
|
+
name: 'cicd',
|
|
2057
|
+
description: 'Manage CI/CD pipelines across GitHub Actions, GitLab CI, and CircleCI. List, trigger, retry, cancel, and fetch logs.',
|
|
2058
|
+
inputSchema: cicdSchema,
|
|
2059
|
+
permissionTier: 'ask_once',
|
|
2060
|
+
category: 'devops',
|
|
2061
|
+
|
|
2062
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
2063
|
+
try {
|
|
2064
|
+
const input = cicdSchema.parse(raw);
|
|
2065
|
+
|
|
2066
|
+
let command: string;
|
|
2067
|
+
|
|
2068
|
+
switch (input.provider) {
|
|
2069
|
+
case 'github': {
|
|
2070
|
+
const repoFlag = input.repo ? `--repo ${input.repo}` : '';
|
|
2071
|
+
switch (input.action) {
|
|
2072
|
+
case 'list':
|
|
2073
|
+
command = `gh workflow list ${repoFlag}`;
|
|
2074
|
+
break;
|
|
2075
|
+
case 'get':
|
|
2076
|
+
command = `gh workflow view ${input.workflow ?? ''} ${repoFlag}`;
|
|
2077
|
+
break;
|
|
2078
|
+
case 'trigger':
|
|
2079
|
+
command = `gh workflow run ${input.workflow ?? ''} ${repoFlag} ${input.branch ? `--ref ${input.branch}` : ''}`.trim();
|
|
2080
|
+
break;
|
|
2081
|
+
case 'retry':
|
|
2082
|
+
command = `gh run rerun ${input.run_id ?? ''} ${repoFlag}`;
|
|
2083
|
+
break;
|
|
2084
|
+
case 'cancel':
|
|
2085
|
+
command = `gh run cancel ${input.run_id ?? ''} ${repoFlag}`;
|
|
2086
|
+
break;
|
|
2087
|
+
case 'logs':
|
|
2088
|
+
command = `gh run view ${input.run_id ?? ''} ${repoFlag} --log 2>&1 | tail -200`;
|
|
2089
|
+
break;
|
|
2090
|
+
case 'status':
|
|
2091
|
+
command = `gh run list ${repoFlag} ${input.workflow ? `--workflow ${input.workflow}` : ''} --limit 10`;
|
|
2092
|
+
break;
|
|
2093
|
+
case 'artifacts':
|
|
2094
|
+
command = `gh run download ${input.run_id ?? ''} ${repoFlag} --dir /tmp/nimbus-artifacts`;
|
|
2095
|
+
break;
|
|
2096
|
+
default:
|
|
2097
|
+
return err(`Unknown action ${input.action} for GitHub Actions`);
|
|
2098
|
+
}
|
|
2099
|
+
break;
|
|
2100
|
+
}
|
|
2101
|
+
case 'gitlab': {
|
|
2102
|
+
switch (input.action) {
|
|
2103
|
+
case 'list':
|
|
2104
|
+
command = `glab ci list`;
|
|
2105
|
+
break;
|
|
2106
|
+
case 'get':
|
|
2107
|
+
command = `glab ci get ${input.run_id ?? ''}`;
|
|
2108
|
+
break;
|
|
2109
|
+
case 'trigger':
|
|
2110
|
+
command = `glab ci run ${input.workflow ?? ''} ${input.branch ? `--ref ${input.branch}` : ''}`.trim();
|
|
2111
|
+
break;
|
|
2112
|
+
case 'retry':
|
|
2113
|
+
command = `glab ci retry ${input.run_id ?? ''}`;
|
|
2114
|
+
break;
|
|
2115
|
+
case 'cancel':
|
|
2116
|
+
command = `glab ci cancel ${input.run_id ?? ''}`;
|
|
2117
|
+
break;
|
|
2118
|
+
case 'logs':
|
|
2119
|
+
command = `glab ci trace ${input.run_id ?? ''} 2>&1 | tail -200`;
|
|
2120
|
+
break;
|
|
2121
|
+
case 'status':
|
|
2122
|
+
command = `glab ci status`;
|
|
2123
|
+
break;
|
|
2124
|
+
case 'artifacts':
|
|
2125
|
+
command = `glab ci artifact ${input.run_id ?? ''}`;
|
|
2126
|
+
break;
|
|
2127
|
+
default:
|
|
2128
|
+
return err(`Unknown action ${input.action} for GitLab CI`);
|
|
2129
|
+
}
|
|
2130
|
+
break;
|
|
2131
|
+
}
|
|
2132
|
+
case 'circleci': {
|
|
2133
|
+
const token = process.env.CIRCLECI_TOKEN;
|
|
2134
|
+
const tokenFlag = token ? `-H "Circle-Token: ${token}"` : '';
|
|
2135
|
+
const slug = input.project_slug ?? input.repo?.replace('/', '/github/') ?? '';
|
|
2136
|
+
switch (input.action) {
|
|
2137
|
+
case 'list':
|
|
2138
|
+
command = `curl -s ${tokenFlag} "https://circleci.com/api/v2/project/github/${slug}/pipeline?limit=20"`;
|
|
2139
|
+
break;
|
|
2140
|
+
case 'get':
|
|
2141
|
+
command = `curl -s ${tokenFlag} "https://circleci.com/api/v2/pipeline/${input.run_id}"`;
|
|
2142
|
+
break;
|
|
2143
|
+
case 'trigger':
|
|
2144
|
+
command = `curl -s -X POST ${tokenFlag} -H "Content-Type: application/json" -d '{"branch":"${input.branch ?? 'main'}"}' "https://circleci.com/api/v2/project/github/${slug}/pipeline"`;
|
|
2145
|
+
break;
|
|
2146
|
+
case 'retry':
|
|
2147
|
+
command = `curl -s -X POST ${tokenFlag} "https://circleci.com/api/v2/workflow/${input.run_id}/rerun"`;
|
|
2148
|
+
break;
|
|
2149
|
+
case 'cancel':
|
|
2150
|
+
command = `curl -s -X POST ${tokenFlag} "https://circleci.com/api/v2/workflow/${input.run_id}/cancel"`;
|
|
2151
|
+
break;
|
|
2152
|
+
case 'logs':
|
|
2153
|
+
command = `curl -s ${tokenFlag} "https://circleci.com/api/v2/workflow/${input.run_id}/job" | head -200`;
|
|
2154
|
+
break;
|
|
2155
|
+
case 'status':
|
|
2156
|
+
command = `curl -s ${tokenFlag} "https://circleci.com/api/v2/project/github/${slug}/pipeline?limit=10"`;
|
|
2157
|
+
break;
|
|
2158
|
+
default:
|
|
2159
|
+
return err(`Unknown action ${input.action} for CircleCI`);
|
|
2160
|
+
}
|
|
2161
|
+
break;
|
|
2162
|
+
}
|
|
2163
|
+
default:
|
|
2164
|
+
return err(`Unknown CI/CD provider: ${input.provider}`);
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
const { stdout, stderr } = await execAsync(command!, {
|
|
2168
|
+
timeout: 60_000,
|
|
2169
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
2173
|
+
// Truncate logs at 200 lines
|
|
2174
|
+
const lines = combined.split('\n');
|
|
2175
|
+
const truncated = lines.length > 200;
|
|
2176
|
+
const output = truncated
|
|
2177
|
+
? lines.slice(0, 200).join('\n') + '\n\n... truncated (showing first 200 lines)'
|
|
2178
|
+
: combined;
|
|
2179
|
+
|
|
2180
|
+
return ok(output || '(no output)');
|
|
2181
|
+
} catch (error: unknown) {
|
|
2182
|
+
return err(`CI/CD operation failed: ${errorMessage(error)}`);
|
|
2183
|
+
}
|
|
2184
|
+
},
|
|
2185
|
+
};
|
|
2186
|
+
|
|
2187
|
+
// ---------------------------------------------------------------------------
|
|
2188
|
+
// 16. monitor (observability)
|
|
2189
|
+
// ---------------------------------------------------------------------------
|
|
2190
|
+
|
|
2191
|
+
const monitorSchema = z.object({
|
|
2192
|
+
action: z.enum(['query','logs','metrics','alerts','dashboards','incidents','ack','resolve','on-call'])
|
|
2193
|
+
.describe('Observability action: query/logs/metrics/alerts/dashboards, or PagerDuty/Opsgenie: incidents/ack/resolve/on-call'),
|
|
2194
|
+
provider: z.enum(['prometheus','cloudwatch','grafana','datadog','newrelic','pagerduty','opsgenie'])
|
|
2195
|
+
.describe('Monitoring or alerting provider'),
|
|
2196
|
+
query: z.string().optional().describe('PromQL, CloudWatch Insights, or metric selector'),
|
|
2197
|
+
namespace: z.string().optional().describe('Metric namespace or Kubernetes namespace'),
|
|
2198
|
+
start_time: z.string().optional().describe('Start time: ISO8601 or relative (-1h, -30m)'),
|
|
2199
|
+
end_time: z.string().optional().describe('End time: ISO8601 or "now"'),
|
|
2200
|
+
region: z.string().optional().describe('Cloud region'),
|
|
2201
|
+
log_group: z.string().optional().describe('CloudWatch log group name'),
|
|
2202
|
+
incident_id: z.string().optional().describe('Incident/alert ID for ack or resolve actions'),
|
|
2203
|
+
});
|
|
2204
|
+
|
|
2205
|
+
export const monitorTool: ToolDefinition = {
|
|
2206
|
+
name: 'monitor',
|
|
2207
|
+
description: 'Query observability data from Prometheus, CloudWatch, Grafana, Datadog, and New Relic. Read-only.',
|
|
2208
|
+
inputSchema: monitorSchema,
|
|
2209
|
+
permissionTier: 'auto_allow',
|
|
2210
|
+
category: 'devops',
|
|
2211
|
+
|
|
2212
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
2213
|
+
try {
|
|
2214
|
+
const input = monitorSchema.parse(raw);
|
|
2215
|
+
|
|
2216
|
+
// Parse relative times
|
|
2217
|
+
function parseTime(t: string | undefined, defaultSecs: number): number {
|
|
2218
|
+
if (!t) return Math.floor(Date.now() / 1000) - defaultSecs;
|
|
2219
|
+
if (t.startsWith('-')) {
|
|
2220
|
+
const val = parseInt(t.slice(1));
|
|
2221
|
+
const unit = t.slice(-1);
|
|
2222
|
+
const mult = unit === 'h' ? 3600 : unit === 'm' ? 60 : 1;
|
|
2223
|
+
return Math.floor(Date.now() / 1000) - val * mult;
|
|
2224
|
+
}
|
|
2225
|
+
return Math.floor(new Date(t).getTime() / 1000);
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
const startTs = parseTime(input.start_time, 3600);
|
|
2229
|
+
const endTs = parseTime(input.end_time, 0);
|
|
2230
|
+
|
|
2231
|
+
switch (input.provider) {
|
|
2232
|
+
case 'prometheus': {
|
|
2233
|
+
const baseUrl = process.env.PROMETHEUS_URL ?? 'http://localhost:9090';
|
|
2234
|
+
const q = encodeURIComponent(input.query ?? 'up');
|
|
2235
|
+
const cmd = `curl -sf "${baseUrl}/api/v1/query_range?query=${q}&start=${startTs}&end=${endTs}&step=60" | head -c 50000`;
|
|
2236
|
+
const { stdout } = await execAsync(cmd, { timeout: 30_000 });
|
|
2237
|
+
try {
|
|
2238
|
+
const data = JSON.parse(stdout);
|
|
2239
|
+
const results = data?.data?.result ?? [];
|
|
2240
|
+
const lines = results.slice(0, 100).map((r: { metric: Record<string, string>; values: [number, string][] }) => {
|
|
2241
|
+
const metric = Object.entries(r.metric).map(([k, v]) => `${k}="${v}"`).join(',');
|
|
2242
|
+
const latest = r.values[r.values.length - 1];
|
|
2243
|
+
return `{${metric}} = ${latest?.[1] ?? 'N/A'} (at ${latest ? new Date(latest[0] * 1000).toISOString() : 'N/A'})`;
|
|
2244
|
+
});
|
|
2245
|
+
return ok(`Prometheus query results (${results.length} series):\n${lines.join('\n')}`);
|
|
2246
|
+
} catch {
|
|
2247
|
+
return ok(stdout.slice(0, 5000));
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
case 'cloudwatch': {
|
|
2252
|
+
const regionFlag = input.region ? `--region ${input.region}` : '';
|
|
2253
|
+
if (input.action === 'logs' && input.log_group) {
|
|
2254
|
+
const cmd = `aws logs filter-log-events --log-group-name ${input.log_group} --start-time ${startTs * 1000} --end-time ${endTs * 1000} ${regionFlag} --output json`;
|
|
2255
|
+
const { stdout } = await execAsync(cmd, { timeout: 60_000, maxBuffer: 5 * 1024 * 1024 });
|
|
2256
|
+
const data = JSON.parse(stdout);
|
|
2257
|
+
const events = (data.events ?? []).slice(0, 100);
|
|
2258
|
+
return ok(events.map((e: { timestamp: number; message: string }) => `[${new Date(e.timestamp).toISOString()}] ${e.message}`).join('\n'));
|
|
2259
|
+
}
|
|
2260
|
+
const metricName = input.query ?? 'CPUUtilization';
|
|
2261
|
+
const ns = input.namespace ?? 'AWS/EC2';
|
|
2262
|
+
const cmd = `aws cloudwatch get-metric-statistics --metric-name ${metricName} --namespace ${ns} --start-time ${new Date(startTs * 1000).toISOString()} --end-time ${new Date(endTs * 1000).toISOString()} --period 300 --statistics Average ${regionFlag} --output json`;
|
|
2263
|
+
const { stdout } = await execAsync(cmd, { timeout: 30_000, maxBuffer: 2 * 1024 * 1024 });
|
|
2264
|
+
return ok(stdout.slice(0, 5000));
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
case 'grafana': {
|
|
2268
|
+
const baseUrl = process.env.GRAFANA_URL ?? 'http://localhost:3000';
|
|
2269
|
+
const token = process.env.GRAFANA_TOKEN ?? '';
|
|
2270
|
+
const authFlag = token ? `-H "Authorization: Bearer ${token}"` : '';
|
|
2271
|
+
const cmd = `curl -sf ${authFlag} "${baseUrl}/api/dashboards/home" | head -c 10000`;
|
|
2272
|
+
const { stdout } = await execAsync(cmd, { timeout: 15_000 });
|
|
2273
|
+
return ok(stdout || '(no dashboards found)');
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
case 'datadog': {
|
|
2277
|
+
const apiKey = process.env.DD_API_KEY ?? '';
|
|
2278
|
+
const appKey = process.env.DD_APP_KEY ?? '';
|
|
2279
|
+
if (!apiKey) return err('DD_API_KEY environment variable not set');
|
|
2280
|
+
const q = encodeURIComponent(input.query ?? 'avg:system.cpu.user{*}');
|
|
2281
|
+
const cmd = `curl -sf -H "DD-API-KEY: ${apiKey}" -H "DD-APPLICATION-KEY: ${appKey}" "https://api.datadoghq.com/api/v1/query?from=${startTs}&to=${endTs}&query=${q}"`;
|
|
2282
|
+
const { stdout } = await execAsync(cmd, { timeout: 30_000 });
|
|
2283
|
+
const data = JSON.parse(stdout);
|
|
2284
|
+
const series = (data.series ?? []).slice(0, 100);
|
|
2285
|
+
return ok(`Datadog query (${series.length} series):\n` + JSON.stringify(series.map((s: { metric: string; pointlist: [number, number][] }) => ({ metric: s.metric, points: s.pointlist.length })), null, 2));
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
case 'newrelic': {
|
|
2289
|
+
const apiKey = process.env.NEW_RELIC_API_KEY ?? '';
|
|
2290
|
+
if (!apiKey) return err('NEW_RELIC_API_KEY environment variable not set');
|
|
2291
|
+
const nrqlQuery = input.query ?? `SELECT average(cpuPercent) FROM SystemSample SINCE 1 hour ago`;
|
|
2292
|
+
const body = JSON.stringify({ query: `{ actor { nrql(accounts: 0, query: "${nrqlQuery.replace(/"/g, '\\"')}") { results } } }` });
|
|
2293
|
+
const cmd = `curl -sf -X POST -H "Content-Type: application/json" -H "API-Key: ${apiKey}" -d '${body.replace(/'/g, "'\\''")}' "https://api.newrelic.com/graphql"`;
|
|
2294
|
+
const { stdout } = await execAsync(cmd, { timeout: 30_000 });
|
|
2295
|
+
return ok(stdout.slice(0, 5000));
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
// Gap 5: PagerDuty alert management
|
|
2299
|
+
case 'pagerduty': {
|
|
2300
|
+
const pdKey = process.env.PD_API_KEY ?? '';
|
|
2301
|
+
if (!pdKey) return err('PD_API_KEY environment variable not set');
|
|
2302
|
+
const authHeader = `-H "Authorization: Token token=${pdKey}" -H "Accept: application/vnd.pagerduty+json;version=2"`;
|
|
2303
|
+
switch (input.action) {
|
|
2304
|
+
case 'incidents':
|
|
2305
|
+
return ok((await execAsync(`curl -sf ${authHeader} "https://api.pagerduty.com/incidents?statuses[]=triggered&statuses[]=acknowledged&limit=25"`, { timeout: 15_000 })).stdout.slice(0, 5000));
|
|
2306
|
+
case 'alerts':
|
|
2307
|
+
return ok((await execAsync(`curl -sf ${authHeader} "https://api.pagerduty.com/alerts?limit=25"`, { timeout: 15_000 })).stdout.slice(0, 5000));
|
|
2308
|
+
case 'ack': {
|
|
2309
|
+
if (!input.incident_id) return err('incident_id required for ack action');
|
|
2310
|
+
const body = JSON.stringify({ incident: { type: 'incident_reference', status: 'acknowledged' } });
|
|
2311
|
+
return ok((await execAsync(`curl -sf -X PUT ${authHeader} -H "Content-Type: application/json" -d '${body}' "https://api.pagerduty.com/incidents/${input.incident_id}"`, { timeout: 15_000 })).stdout.slice(0, 2000));
|
|
2312
|
+
}
|
|
2313
|
+
case 'resolve': {
|
|
2314
|
+
if (!input.incident_id) return err('incident_id required for resolve action');
|
|
2315
|
+
const body = JSON.stringify({ incident: { type: 'incident_reference', status: 'resolved' } });
|
|
2316
|
+
return ok((await execAsync(`curl -sf -X PUT ${authHeader} -H "Content-Type: application/json" -d '${body}' "https://api.pagerduty.com/incidents/${input.incident_id}"`, { timeout: 15_000 })).stdout.slice(0, 2000));
|
|
2317
|
+
}
|
|
2318
|
+
case 'on-call':
|
|
2319
|
+
return ok((await execAsync(`curl -sf ${authHeader} "https://api.pagerduty.com/oncalls?limit=25"`, { timeout: 15_000 })).stdout.slice(0, 3000));
|
|
2320
|
+
default:
|
|
2321
|
+
return err(`PagerDuty action not supported: ${input.action}`);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// Gap 5: Opsgenie alert management
|
|
2326
|
+
case 'opsgenie': {
|
|
2327
|
+
const ogKey = process.env.OPSGENIE_API_KEY ?? '';
|
|
2328
|
+
if (!ogKey) return err('OPSGENIE_API_KEY environment variable not set');
|
|
2329
|
+
const authHeader = `-H "Authorization: GenieKey ${ogKey}"`;
|
|
2330
|
+
switch (input.action) {
|
|
2331
|
+
case 'alerts':
|
|
2332
|
+
case 'incidents':
|
|
2333
|
+
return ok((await execAsync(`curl -sf ${authHeader} "https://api.opsgenie.com/v2/alerts?limit=25"`, { timeout: 15_000 })).stdout.slice(0, 5000));
|
|
2334
|
+
case 'ack': {
|
|
2335
|
+
if (!input.incident_id) return err('incident_id required for ack action');
|
|
2336
|
+
const body = JSON.stringify({ note: 'Acknowledged via Nimbus' });
|
|
2337
|
+
return ok((await execAsync(`curl -sf -X POST ${authHeader} -H "Content-Type: application/json" -d '${JSON.stringify(body)}' "https://api.opsgenie.com/v2/alerts/${input.incident_id}/acknowledge"`, { timeout: 15_000 })).stdout.slice(0, 2000));
|
|
2338
|
+
}
|
|
2339
|
+
case 'resolve': {
|
|
2340
|
+
if (!input.incident_id) return err('incident_id required for resolve action');
|
|
2341
|
+
const body = JSON.stringify({ note: 'Resolved via Nimbus' });
|
|
2342
|
+
return ok((await execAsync(`curl -sf -X POST ${authHeader} -H "Content-Type: application/json" -d '${JSON.stringify(body)}' "https://api.opsgenie.com/v2/alerts/${input.incident_id}/close"`, { timeout: 15_000 })).stdout.slice(0, 2000));
|
|
2343
|
+
}
|
|
2344
|
+
case 'on-call':
|
|
2345
|
+
return ok((await execAsync(`curl -sf ${authHeader} "https://api.opsgenie.com/v2/schedules/on-calls"`, { timeout: 15_000 })).stdout.slice(0, 3000));
|
|
2346
|
+
default:
|
|
2347
|
+
return err(`Opsgenie action not supported: ${input.action}`);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
default:
|
|
2352
|
+
return err(`Unknown monitoring provider: ${input.provider}`);
|
|
2353
|
+
}
|
|
2354
|
+
} catch (error: unknown) {
|
|
2355
|
+
return err(`Monitoring query failed: ${errorMessage(error)}`);
|
|
2356
|
+
}
|
|
2357
|
+
},
|
|
2358
|
+
};
|
|
2359
|
+
|
|
2360
|
+
// ---------------------------------------------------------------------------
|
|
2361
|
+
// 17. gitops (ArgoCD & Flux)
|
|
2362
|
+
// ---------------------------------------------------------------------------
|
|
2363
|
+
|
|
2364
|
+
const gitopsSchema = z.object({
|
|
2365
|
+
action: z.enum(['list','get','sync','reconcile','diff','history','rollback','health','logs','argocd-status','flux-status'])
|
|
2366
|
+
.describe('GitOps action to perform. argocd-status/flux-status: concise cluster-wide status summary'),
|
|
2367
|
+
provider: z.enum(['argocd','flux'])
|
|
2368
|
+
.describe('GitOps provider'),
|
|
2369
|
+
app: z.string().optional().describe('Application or HelmRelease name'),
|
|
2370
|
+
namespace: z.string().optional().describe('Kubernetes namespace'),
|
|
2371
|
+
server: z.string().optional().describe('ArgoCD server URL (or use ARGOCD_SERVER env)'),
|
|
2372
|
+
revision: z.string().optional().describe('Revision or rollback target'),
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
export const gitopsTool: ToolDefinition = {
|
|
2376
|
+
name: 'gitops',
|
|
2377
|
+
description: 'Manage GitOps deployments via ArgoCD and Flux. Sync apps, check health, view diffs, and rollback.',
|
|
2378
|
+
inputSchema: gitopsSchema,
|
|
2379
|
+
permissionTier: 'ask_once',
|
|
2380
|
+
category: 'devops',
|
|
2381
|
+
isDestructive: false,
|
|
2382
|
+
|
|
2383
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
2384
|
+
try {
|
|
2385
|
+
const input = gitopsSchema.parse(raw);
|
|
2386
|
+
|
|
2387
|
+
let command: string;
|
|
2388
|
+
|
|
2389
|
+
if (input.provider === 'argocd') {
|
|
2390
|
+
const server = input.server ?? process.env.ARGOCD_SERVER ?? '';
|
|
2391
|
+
const serverFlag = server ? `--server ${server}` : '';
|
|
2392
|
+
const token = process.env.ARGOCD_TOKEN ?? '';
|
|
2393
|
+
const tokenFlag = token ? `--auth-token ${token}` : '';
|
|
2394
|
+
const flags = [serverFlag, tokenFlag, '--grpc-web'].filter(Boolean).join(' ');
|
|
2395
|
+
const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
|
|
2396
|
+
|
|
2397
|
+
switch (input.action) {
|
|
2398
|
+
case 'list':
|
|
2399
|
+
command = `argocd app list ${flags}`;
|
|
2400
|
+
break;
|
|
2401
|
+
case 'get':
|
|
2402
|
+
command = `argocd app get ${input.app ?? ''} ${flags}`;
|
|
2403
|
+
break;
|
|
2404
|
+
case 'sync':
|
|
2405
|
+
command = `argocd app sync ${input.app ?? ''} ${flags}`;
|
|
2406
|
+
break;
|
|
2407
|
+
case 'diff':
|
|
2408
|
+
command = `argocd app diff ${input.app ?? ''} ${flags}`;
|
|
2409
|
+
break;
|
|
2410
|
+
case 'history':
|
|
2411
|
+
command = `argocd app history ${input.app ?? ''} ${flags}`;
|
|
2412
|
+
break;
|
|
2413
|
+
case 'rollback':
|
|
2414
|
+
command = `argocd app rollback ${input.app ?? ''} ${input.revision ?? ''} ${flags}`;
|
|
2415
|
+
break;
|
|
2416
|
+
case 'health':
|
|
2417
|
+
command = `argocd app get ${input.app ?? ''} ${flags} -o json`;
|
|
2418
|
+
break;
|
|
2419
|
+
case 'logs':
|
|
2420
|
+
command = `argocd app logs ${input.app ?? ''} ${flags} ${nsFlag} --tail=200`;
|
|
2421
|
+
break;
|
|
2422
|
+
case 'argocd-status':
|
|
2423
|
+
command = `argocd app list ${flags} -o wide`;
|
|
2424
|
+
break;
|
|
2425
|
+
case 'flux-status':
|
|
2426
|
+
command = `argocd app list ${flags}`;
|
|
2427
|
+
break;
|
|
2428
|
+
default:
|
|
2429
|
+
return err(`Action ${input.action} not supported for ArgoCD`);
|
|
2430
|
+
}
|
|
2431
|
+
} else if (input.provider === 'flux') {
|
|
2432
|
+
const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
|
|
2433
|
+
switch (input.action) {
|
|
2434
|
+
case 'list':
|
|
2435
|
+
command = `flux get all ${nsFlag}`;
|
|
2436
|
+
break;
|
|
2437
|
+
case 'get':
|
|
2438
|
+
command = `flux get kustomizations ${input.app ?? ''} ${nsFlag}`;
|
|
2439
|
+
break;
|
|
2440
|
+
case 'sync':
|
|
2441
|
+
case 'reconcile':
|
|
2442
|
+
command = `flux reconcile kustomization ${input.app ?? 'flux-system'} ${nsFlag}`;
|
|
2443
|
+
break;
|
|
2444
|
+
case 'diff':
|
|
2445
|
+
command = `flux diff kustomization ${input.app ?? ''} ${nsFlag}`;
|
|
2446
|
+
break;
|
|
2447
|
+
case 'history':
|
|
2448
|
+
command = `kubectl get events ${nsFlag} --field-selector reason=ReconcileSucceeded`;
|
|
2449
|
+
break;
|
|
2450
|
+
case 'rollback':
|
|
2451
|
+
return err('Flux rollback: revert the Git commit and reconcile to roll back');
|
|
2452
|
+
case 'health':
|
|
2453
|
+
command = `flux get all ${nsFlag} -o json`;
|
|
2454
|
+
break;
|
|
2455
|
+
case 'logs':
|
|
2456
|
+
command = `flux logs ${nsFlag} --tail=200`;
|
|
2457
|
+
break;
|
|
2458
|
+
case 'flux-status':
|
|
2459
|
+
command = `flux get all ${nsFlag}`;
|
|
2460
|
+
break;
|
|
2461
|
+
case 'argocd-status':
|
|
2462
|
+
command = `flux get all ${nsFlag} -o json`;
|
|
2463
|
+
break;
|
|
2464
|
+
default:
|
|
2465
|
+
return err(`Action ${input.action} not supported for Flux`);
|
|
2466
|
+
}
|
|
2467
|
+
} else {
|
|
2468
|
+
return err(`Unknown provider: ${input.provider}`);
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
const { stdout, stderr } = await execAsync(command!, {
|
|
2472
|
+
timeout: 120_000,
|
|
2473
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
2474
|
+
});
|
|
2475
|
+
|
|
2476
|
+
// For health action, parse and simplify ArgoCD JSON
|
|
2477
|
+
if (input.action === 'health' && input.provider === 'argocd') {
|
|
2478
|
+
try {
|
|
2479
|
+
const app = JSON.parse(stdout);
|
|
2480
|
+
const health = app?.status?.health?.status ?? 'Unknown';
|
|
2481
|
+
const sync = app?.status?.sync?.status ?? 'Unknown';
|
|
2482
|
+
const conditions = (app?.status?.conditions ?? []).map((c: { type: string; message: string }) => ` ${c.type}: ${c.message}`).join('\n');
|
|
2483
|
+
return ok(`App: ${app?.metadata?.name}\nHealth: ${health}\nSync: ${sync}\n${conditions ? 'Conditions:\n' + conditions : ''}`);
|
|
2484
|
+
} catch {
|
|
2485
|
+
// Fall through to raw output
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
2490
|
+
return ok(combined || '(no output)');
|
|
2491
|
+
} catch (error: unknown) {
|
|
2492
|
+
return err(`GitOps operation failed: ${errorMessage(error)}`);
|
|
2493
|
+
}
|
|
2494
|
+
},
|
|
2495
|
+
};
|
|
2496
|
+
|
|
2497
|
+
// ---------------------------------------------------------------------------
|
|
2498
|
+
// 18. cloud_action
|
|
2499
|
+
// ---------------------------------------------------------------------------
|
|
2500
|
+
|
|
2501
|
+
const cloudActionSchema = z.object({
|
|
2502
|
+
action: z.enum(['start','stop','restart','create','delete','scale','describe','list'])
|
|
2503
|
+
.describe('Action to perform on the cloud resource'),
|
|
2504
|
+
provider: z.enum(['aws','gcp','azure'])
|
|
2505
|
+
.describe('Cloud provider'),
|
|
2506
|
+
service: z.string().describe('Service type: ec2, rds, eks, ecs, gce, gke, aks, functions, etc.'),
|
|
2507
|
+
resource_id: z.string().optional().describe('Resource ID, name, or ARN'),
|
|
2508
|
+
config: z.record(z.string(), z.unknown()).optional().describe('Additional configuration parameters'),
|
|
2509
|
+
region: z.string().optional().describe('Cloud region'),
|
|
2510
|
+
});
|
|
2511
|
+
|
|
2512
|
+
export const cloudActionTool: ToolDefinition = {
|
|
2513
|
+
name: 'cloud_action',
|
|
2514
|
+
description: 'Perform actions on cloud resources (start/stop/scale/create/delete) across AWS, GCP, and Azure.',
|
|
2515
|
+
inputSchema: cloudActionSchema,
|
|
2516
|
+
permissionTier: 'ask_once',
|
|
2517
|
+
category: 'devops',
|
|
2518
|
+
isDestructive: true,
|
|
2519
|
+
|
|
2520
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
2521
|
+
try {
|
|
2522
|
+
const input = cloudActionSchema.parse(raw);
|
|
2523
|
+
const regionFlag = input.region ? `--region ${input.region}` : '';
|
|
2524
|
+
const id = input.resource_id ?? '';
|
|
2525
|
+
|
|
2526
|
+
let command: string;
|
|
2527
|
+
|
|
2528
|
+
if (input.provider === 'aws') {
|
|
2529
|
+
switch (`${input.service}:${input.action}`) {
|
|
2530
|
+
case 'ec2:start':
|
|
2531
|
+
command = `aws ec2 start-instances --instance-ids ${id} ${regionFlag} --output json`;
|
|
2532
|
+
break;
|
|
2533
|
+
case 'ec2:stop':
|
|
2534
|
+
command = `aws ec2 stop-instances --instance-ids ${id} ${regionFlag} --output json`;
|
|
2535
|
+
break;
|
|
2536
|
+
case 'ec2:describe':
|
|
2537
|
+
case 'ec2:list':
|
|
2538
|
+
command = `aws ec2 describe-instances --instance-ids ${id} ${regionFlag} --output json`;
|
|
2539
|
+
break;
|
|
2540
|
+
case 'rds:start':
|
|
2541
|
+
command = `aws rds start-db-instance --db-instance-identifier ${id} ${regionFlag}`;
|
|
2542
|
+
break;
|
|
2543
|
+
case 'rds:stop':
|
|
2544
|
+
command = `aws rds stop-db-instance --db-instance-identifier ${id} ${regionFlag}`;
|
|
2545
|
+
break;
|
|
2546
|
+
case 'ecs:scale':
|
|
2547
|
+
const desired = (input.config as Record<string,unknown>)?.desired ?? 1;
|
|
2548
|
+
command = `aws ecs update-service --service ${id} --desired-count ${desired} ${regionFlag}`;
|
|
2549
|
+
break;
|
|
2550
|
+
default:
|
|
2551
|
+
command = `aws ${input.service} ${input.action} ${id} ${regionFlag} --output json`;
|
|
2552
|
+
}
|
|
2553
|
+
} else if (input.provider === 'gcp') {
|
|
2554
|
+
switch (`${input.service}:${input.action}`) {
|
|
2555
|
+
case 'gce:start':
|
|
2556
|
+
command = `gcloud compute instances start ${id}`;
|
|
2557
|
+
break;
|
|
2558
|
+
case 'gce:stop':
|
|
2559
|
+
command = `gcloud compute instances stop ${id}`;
|
|
2560
|
+
break;
|
|
2561
|
+
default:
|
|
2562
|
+
command = `gcloud ${input.service} ${input.action} ${id} --format=json`;
|
|
2563
|
+
}
|
|
2564
|
+
} else if (input.provider === 'azure') {
|
|
2565
|
+
switch (`${input.service}:${input.action}`) {
|
|
2566
|
+
case 'vm:start':
|
|
2567
|
+
command = `az vm start --name ${id} --output json`;
|
|
2568
|
+
break;
|
|
2569
|
+
case 'vm:stop':
|
|
2570
|
+
command = `az vm stop --name ${id} --output json`;
|
|
2571
|
+
break;
|
|
2572
|
+
default:
|
|
2573
|
+
command = `az ${input.service} ${input.action} --name ${id} --output json`;
|
|
2574
|
+
}
|
|
2575
|
+
} else {
|
|
2576
|
+
return err(`Unknown provider: ${input.provider}`);
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
const { stdout, stderr } = await execAsync(command!, {
|
|
2580
|
+
timeout: 120_000,
|
|
2581
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
2585
|
+
return ok(combined || '(success)');
|
|
2586
|
+
} catch (error: unknown) {
|
|
2587
|
+
return err(`Cloud action failed: ${errorMessage(error)}`);
|
|
2588
|
+
}
|
|
2589
|
+
},
|
|
2590
|
+
};
|
|
2591
|
+
|
|
2592
|
+
// ---------------------------------------------------------------------------
|
|
2593
|
+
// 19. logs (log streaming)
|
|
2594
|
+
// ---------------------------------------------------------------------------
|
|
2595
|
+
|
|
2596
|
+
const logsSchema = z.object({
|
|
2597
|
+
action: z.enum(['tail','search','download'])
|
|
2598
|
+
.describe('Log action to perform'),
|
|
2599
|
+
provider: z.enum(['cloudwatch','kubernetes','loki','elasticsearch'])
|
|
2600
|
+
.describe('Log provider'),
|
|
2601
|
+
source: z.string().describe('Log group, pod name, Loki label selector, or index'),
|
|
2602
|
+
filter: z.string().optional().describe('Filter expression or query string'),
|
|
2603
|
+
lines: z.number().optional().default(100).describe('Number of lines to retrieve (default: 100)'),
|
|
2604
|
+
since: z.string().optional().describe('Time range: -1h, -30m, or ISO8601'),
|
|
2605
|
+
namespace: z.string().optional().describe('Kubernetes namespace'),
|
|
2606
|
+
region: z.string().optional().describe('Cloud region'),
|
|
2607
|
+
follow: z.boolean().optional().default(false).describe('Follow/stream logs in real-time (only valid for kubernetes provider)'),
|
|
2608
|
+
});
|
|
2609
|
+
|
|
2610
|
+
export const logsTool: ToolDefinition = {
|
|
2611
|
+
name: 'logs',
|
|
2612
|
+
description: 'Tail, search, or download logs from CloudWatch, Kubernetes pods, Loki, or Elasticsearch. Read-only.',
|
|
2613
|
+
inputSchema: logsSchema,
|
|
2614
|
+
permissionTier: 'auto_allow',
|
|
2615
|
+
category: 'devops',
|
|
2616
|
+
|
|
2617
|
+
async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
|
|
2618
|
+
try {
|
|
2619
|
+
const input = logsSchema.parse(raw);
|
|
2620
|
+
const maxLines = Math.min(input.lines ?? 100, 200);
|
|
2621
|
+
|
|
2622
|
+
let command: string;
|
|
2623
|
+
|
|
2624
|
+
switch (input.provider) {
|
|
2625
|
+
case 'kubernetes': {
|
|
2626
|
+
const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
|
|
2627
|
+
const sinceFlag = input.since ? `--since=${input.since.replace('-', '')}` : '';
|
|
2628
|
+
const followFlag = input.follow ? '-f' : `--tail=${maxLines}`;
|
|
2629
|
+
command = `kubectl logs ${input.source} ${nsFlag} ${sinceFlag} ${followFlag} ${input.filter ? `| grep ${input.filter}` : ''}`.trim();
|
|
2630
|
+
|
|
2631
|
+
// For follow mode, use spawnExec with streaming
|
|
2632
|
+
if (input.follow && ctx?.onProgress) {
|
|
2633
|
+
const timeoutMs = ctx?.timeout ?? 300_000;
|
|
2634
|
+
const abortController = new AbortController();
|
|
2635
|
+
if (ctx?.signal) {
|
|
2636
|
+
ctx.signal.addEventListener('abort', () => abortController.abort());
|
|
2637
|
+
}
|
|
2638
|
+
const spawnResult = await spawnExec(command, { onChunk: ctx.onProgress, timeout: timeoutMs });
|
|
2639
|
+
const combined = [spawnResult.stdout, spawnResult.stderr].filter(Boolean).join('\n');
|
|
2640
|
+
return ok(combined || '(log stream ended)');
|
|
2641
|
+
}
|
|
2642
|
+
break;
|
|
2643
|
+
}
|
|
2644
|
+
case 'cloudwatch': {
|
|
2645
|
+
const regionFlag = input.region ? `--region ${input.region}` : '';
|
|
2646
|
+
const endMs = Date.now();
|
|
2647
|
+
const sinceMs = input.since
|
|
2648
|
+
? (input.since.startsWith('-')
|
|
2649
|
+
? Date.now() - parseInt(input.since.slice(1)) * (input.since.endsWith('h') ? 3600000 : 60000)
|
|
2650
|
+
: new Date(input.since).getTime())
|
|
2651
|
+
: endMs - 3600000;
|
|
2652
|
+
command = `aws logs filter-log-events --log-group-name ${input.source} --start-time ${sinceMs} --end-time ${endMs} ${input.filter ? `--filter-pattern "${input.filter}"` : ''} ${regionFlag} --output json`;
|
|
2653
|
+
break;
|
|
2654
|
+
}
|
|
2655
|
+
case 'loki': {
|
|
2656
|
+
const lokiUrl = process.env.LOKI_URL ?? 'http://localhost:3100';
|
|
2657
|
+
const q = encodeURIComponent(input.filter ? `{${input.source}} |= "${input.filter}"` : `{${input.source}}`);
|
|
2658
|
+
command = `curl -sf "${lokiUrl}/loki/api/v1/query_range?query=${q}&limit=${maxLines}" | head -c 50000`;
|
|
2659
|
+
break;
|
|
2660
|
+
}
|
|
2661
|
+
case 'elasticsearch': {
|
|
2662
|
+
const esUrl = process.env.ELASTICSEARCH_URL ?? 'http://localhost:9200';
|
|
2663
|
+
const body = JSON.stringify({ query: { match_all: {} }, size: maxLines });
|
|
2664
|
+
command = `curl -sf -X POST "${esUrl}/${input.source}/_search" -H "Content-Type: application/json" -d '${body.replace(/'/g, "'\\''")}' | head -c 50000`;
|
|
2665
|
+
break;
|
|
2666
|
+
}
|
|
2667
|
+
default:
|
|
2668
|
+
return err(`Unknown log provider: ${input.provider}`);
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
const { stdout, stderr } = await execAsync(command!, {
|
|
2672
|
+
timeout: 60_000,
|
|
2673
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2676
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
2677
|
+
const lines = combined.split('\n');
|
|
2678
|
+
const output = lines.length > maxLines
|
|
2679
|
+
? lines.slice(0, maxLines).join('\n') + `\n\n... truncated at ${maxLines} lines`
|
|
2680
|
+
: combined;
|
|
2681
|
+
|
|
2682
|
+
return ok(output || '(no logs found)');
|
|
2683
|
+
} catch (error: unknown) {
|
|
2684
|
+
return err(`Log query failed: ${errorMessage(error)}`);
|
|
2685
|
+
}
|
|
2686
|
+
},
|
|
2687
|
+
};
|
|
2688
|
+
|
|
2689
|
+
// ---------------------------------------------------------------------------
|
|
2690
|
+
// 20. certs (certificate management)
|
|
2691
|
+
// ---------------------------------------------------------------------------
|
|
2692
|
+
|
|
2693
|
+
const certsSchema = z.object({
|
|
2694
|
+
action: z.enum(['list','get','renew','issue','delete','status'])
|
|
2695
|
+
.describe('Certificate action to perform'),
|
|
2696
|
+
provider: z.enum(['cert-manager','acm','gcp','letsencrypt'])
|
|
2697
|
+
.describe('Certificate provider'),
|
|
2698
|
+
domain: z.string().optional().describe('Domain name'),
|
|
2699
|
+
namespace: z.string().optional().describe('Kubernetes namespace for cert-manager'),
|
|
2700
|
+
arn: z.string().optional().describe('ACM certificate ARN'),
|
|
2701
|
+
});
|
|
2702
|
+
|
|
2703
|
+
export const certsTool: ToolDefinition = {
|
|
2704
|
+
name: 'certs',
|
|
2705
|
+
description: "Manage TLS certificates via cert-manager, AWS ACM, GCP Certificate Manager, and Let\'s Encrypt.",
|
|
2706
|
+
inputSchema: certsSchema,
|
|
2707
|
+
permissionTier: 'ask_once',
|
|
2708
|
+
category: 'devops',
|
|
2709
|
+
|
|
2710
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
2711
|
+
try {
|
|
2712
|
+
const input = certsSchema.parse(raw);
|
|
2713
|
+
const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
|
|
2714
|
+
|
|
2715
|
+
let command: string;
|
|
2716
|
+
|
|
2717
|
+
switch (input.provider) {
|
|
2718
|
+
case 'cert-manager':
|
|
2719
|
+
switch (input.action) {
|
|
2720
|
+
case 'list':
|
|
2721
|
+
command = `kubectl get certificates ${nsFlag} -o wide`;
|
|
2722
|
+
break;
|
|
2723
|
+
case 'get':
|
|
2724
|
+
command = `kubectl describe certificate ${input.domain ?? ''} ${nsFlag}`;
|
|
2725
|
+
break;
|
|
2726
|
+
case 'status':
|
|
2727
|
+
command = `kubectl get certificaterequest ${nsFlag} -o wide`;
|
|
2728
|
+
break;
|
|
2729
|
+
case 'renew':
|
|
2730
|
+
command = `kubectl annotate certificate ${input.domain ?? ''} ${nsFlag} cert-manager.io/issuer-name=$(kubectl get cert ${input.domain ?? ''} ${nsFlag} -o jsonpath='{.spec.issuerRef.name}') --overwrite`;
|
|
2731
|
+
break;
|
|
2732
|
+
case 'issue':
|
|
2733
|
+
return err('Issue via cert-manager: create a Certificate resource manifest and apply with kubectl');
|
|
2734
|
+
case 'delete':
|
|
2735
|
+
command = `kubectl delete certificate ${input.domain ?? ''} ${nsFlag}`;
|
|
2736
|
+
break;
|
|
2737
|
+
}
|
|
2738
|
+
break;
|
|
2739
|
+
case 'acm':
|
|
2740
|
+
switch (input.action) {
|
|
2741
|
+
case 'list':
|
|
2742
|
+
command = `aws acm list-certificates --output json`;
|
|
2743
|
+
break;
|
|
2744
|
+
case 'get':
|
|
2745
|
+
case 'status':
|
|
2746
|
+
command = `aws acm describe-certificate --certificate-arn ${input.arn ?? ''} --output json`;
|
|
2747
|
+
break;
|
|
2748
|
+
case 'renew':
|
|
2749
|
+
command = `aws acm renew-certificate --certificate-arn ${input.arn ?? ''}`;
|
|
2750
|
+
break;
|
|
2751
|
+
case 'issue':
|
|
2752
|
+
command = `aws acm request-certificate --domain-name ${input.domain ?? ''} --validation-method DNS --output json`;
|
|
2753
|
+
break;
|
|
2754
|
+
case 'delete':
|
|
2755
|
+
command = `aws acm delete-certificate --certificate-arn ${input.arn ?? ''}`;
|
|
2756
|
+
break;
|
|
2757
|
+
}
|
|
2758
|
+
break;
|
|
2759
|
+
case 'gcp':
|
|
2760
|
+
switch (input.action) {
|
|
2761
|
+
case 'list':
|
|
2762
|
+
command = `gcloud certificate-manager certificates list --format=json`;
|
|
2763
|
+
break;
|
|
2764
|
+
case 'get':
|
|
2765
|
+
case 'status':
|
|
2766
|
+
command = `gcloud certificate-manager certificates describe ${input.domain ?? ''} --format=json`;
|
|
2767
|
+
break;
|
|
2768
|
+
case 'issue':
|
|
2769
|
+
command = `gcloud certificate-manager certificates create ${input.domain ?? ''} --domains=${input.domain ?? ''} --format=json`;
|
|
2770
|
+
break;
|
|
2771
|
+
case 'delete':
|
|
2772
|
+
command = `gcloud certificate-manager certificates delete ${input.domain ?? ''} --quiet`;
|
|
2773
|
+
break;
|
|
2774
|
+
default:
|
|
2775
|
+
return err(`Action ${input.action} not supported for GCP Certificate Manager`);
|
|
2776
|
+
}
|
|
2777
|
+
break;
|
|
2778
|
+
case 'letsencrypt':
|
|
2779
|
+
switch (input.action) {
|
|
2780
|
+
case 'issue':
|
|
2781
|
+
command = `certbot certonly --standalone -d ${input.domain ?? ''} --non-interactive --agree-tos`;
|
|
2782
|
+
break;
|
|
2783
|
+
case 'renew':
|
|
2784
|
+
command = `certbot renew --cert-name ${input.domain ?? ''} --non-interactive`;
|
|
2785
|
+
break;
|
|
2786
|
+
case 'list':
|
|
2787
|
+
command = `certbot certificates`;
|
|
2788
|
+
break;
|
|
2789
|
+
case 'status':
|
|
2790
|
+
command = `certbot certificates --cert-name ${input.domain ?? ''}`;
|
|
2791
|
+
break;
|
|
2792
|
+
default:
|
|
2793
|
+
return err(`Action ${input.action} not supported for Let\'s Encrypt`);
|
|
2794
|
+
}
|
|
2795
|
+
break;
|
|
2796
|
+
default:
|
|
2797
|
+
return err(`Unknown certificate provider: ${input.provider}`);
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
const { stdout, stderr } = await execAsync(command!, {
|
|
2801
|
+
timeout: 120_000,
|
|
2802
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
2803
|
+
});
|
|
2804
|
+
|
|
2805
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
2806
|
+
return ok(combined || '(success)');
|
|
2807
|
+
} catch (error: unknown) {
|
|
2808
|
+
return err(`Certificate operation failed: ${errorMessage(error)}`);
|
|
2809
|
+
}
|
|
2810
|
+
},
|
|
2811
|
+
};
|
|
2812
|
+
|
|
2813
|
+
// ---------------------------------------------------------------------------
|
|
2814
|
+
// 21. mesh (service mesh — Istio & Linkerd)
|
|
2815
|
+
// ---------------------------------------------------------------------------
|
|
2816
|
+
|
|
2817
|
+
const meshSchema = z.object({
|
|
2818
|
+
action: z.enum(['status','traffic-split','mtls-status','virtual-service','gateway','inject','tap','routes'])
|
|
2819
|
+
.describe('Service mesh action to perform'),
|
|
2820
|
+
provider: z.enum(['istio','linkerd'])
|
|
2821
|
+
.describe('Service mesh provider'),
|
|
2822
|
+
namespace: z.string().optional().describe('Kubernetes namespace'),
|
|
2823
|
+
service: z.string().optional().describe('Service name'),
|
|
2824
|
+
args: z.string().optional().describe('Additional arguments'),
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
export const meshTool: ToolDefinition = {
|
|
2828
|
+
name: 'mesh',
|
|
2829
|
+
description: 'Manage Istio and Linkerd service mesh operations: traffic splitting, mTLS status, virtual services, and routes.',
|
|
2830
|
+
inputSchema: meshSchema,
|
|
2831
|
+
permissionTier: 'ask_once',
|
|
2832
|
+
category: 'devops',
|
|
2833
|
+
|
|
2834
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
2835
|
+
try {
|
|
2836
|
+
const input = meshSchema.parse(raw);
|
|
2837
|
+
const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
|
|
2838
|
+
|
|
2839
|
+
let command: string;
|
|
2840
|
+
|
|
2841
|
+
if (input.provider === 'istio') {
|
|
2842
|
+
switch (input.action) {
|
|
2843
|
+
case 'status':
|
|
2844
|
+
command = `istioctl proxy-status ${input.service ?? ''} ${nsFlag}`;
|
|
2845
|
+
break;
|
|
2846
|
+
case 'mtls-status':
|
|
2847
|
+
command = `istioctl x describe pod ${input.service ?? ''} ${nsFlag}`;
|
|
2848
|
+
break;
|
|
2849
|
+
case 'virtual-service':
|
|
2850
|
+
command = `kubectl get virtualservice ${input.service ?? ''} ${nsFlag} -o yaml`;
|
|
2851
|
+
break;
|
|
2852
|
+
case 'gateway':
|
|
2853
|
+
command = `kubectl get gateway ${nsFlag} -o yaml`;
|
|
2854
|
+
break;
|
|
2855
|
+
case 'inject':
|
|
2856
|
+
return err('Inject: use `kubectl label namespace <ns> istio-injection=enabled` and redeploy');
|
|
2857
|
+
case 'tap':
|
|
2858
|
+
command = `istioctl proxy-config ${input.args ?? 'cluster'} ${input.service ?? ''} ${nsFlag}`;
|
|
2859
|
+
break;
|
|
2860
|
+
case 'routes':
|
|
2861
|
+
command = `istioctl proxy-config routes ${input.service ?? ''} ${nsFlag}`;
|
|
2862
|
+
break;
|
|
2863
|
+
case 'traffic-split':
|
|
2864
|
+
command = `kubectl get virtualservice,destinationrule ${nsFlag} -o yaml`;
|
|
2865
|
+
break;
|
|
2866
|
+
default:
|
|
2867
|
+
return err(`Unknown action ${input.action} for Istio`);
|
|
2868
|
+
}
|
|
2869
|
+
} else if (input.provider === 'linkerd') {
|
|
2870
|
+
switch (input.action) {
|
|
2871
|
+
case 'status':
|
|
2872
|
+
command = `linkerd check ${nsFlag}`;
|
|
2873
|
+
break;
|
|
2874
|
+
case 'mtls-status':
|
|
2875
|
+
command = `linkerd edges pod ${nsFlag}`;
|
|
2876
|
+
break;
|
|
2877
|
+
case 'tap':
|
|
2878
|
+
command = `linkerd tap ${input.service ?? ''} ${nsFlag} ${input.args ?? ''}`.trim();
|
|
2879
|
+
break;
|
|
2880
|
+
case 'routes':
|
|
2881
|
+
command = `linkerd routes ${input.service ?? ''} ${nsFlag}`;
|
|
2882
|
+
break;
|
|
2883
|
+
case 'traffic-split':
|
|
2884
|
+
command = `kubectl get trafficsplit ${nsFlag} -o yaml`;
|
|
2885
|
+
break;
|
|
2886
|
+
default:
|
|
2887
|
+
return err(`Action ${input.action} not supported for Linkerd`);
|
|
2888
|
+
}
|
|
2889
|
+
} else {
|
|
2890
|
+
return err(`Unknown service mesh provider: ${input.provider}`);
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
const { stdout, stderr } = await execAsync(command!, {
|
|
2894
|
+
timeout: 60_000,
|
|
2895
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2898
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
2899
|
+
return ok(combined || '(no output)');
|
|
2900
|
+
} catch (error: unknown) {
|
|
2901
|
+
return err(`Service mesh operation failed: ${errorMessage(error)}`);
|
|
2902
|
+
}
|
|
2903
|
+
},
|
|
2904
|
+
};
|
|
2905
|
+
|
|
2906
|
+
// ---------------------------------------------------------------------------
|
|
2907
|
+
// 22. cfn (CloudFormation & CDK)
|
|
2908
|
+
// ---------------------------------------------------------------------------
|
|
2909
|
+
|
|
2910
|
+
const cfnSchema = z.object({
|
|
2911
|
+
action: z.enum(['list','describe','create','update','delete','validate','events','drift','deploy','diff'])
|
|
2912
|
+
.describe('CloudFormation/CDK action'),
|
|
2913
|
+
stack_name: z.string().optional().describe('CloudFormation stack name'),
|
|
2914
|
+
template: z.string().optional().describe('Template file path or URL'),
|
|
2915
|
+
parameters: z.string().optional().describe('Key=Value pairs for stack parameters'),
|
|
2916
|
+
region: z.string().optional().describe('AWS region'),
|
|
2917
|
+
provider: z.enum(['cloudformation','cdk']).default('cloudformation').describe('IaC provider'),
|
|
2918
|
+
});
|
|
2919
|
+
|
|
2920
|
+
export const cfnTool: ToolDefinition = {
|
|
2921
|
+
name: 'cfn',
|
|
2922
|
+
description: 'Manage AWS CloudFormation stacks and CDK applications: list, describe, create, update, delete, validate, and detect drift.',
|
|
2923
|
+
inputSchema: cfnSchema,
|
|
2924
|
+
permissionTier: 'ask_once',
|
|
2925
|
+
category: 'devops',
|
|
2926
|
+
isDestructive: true,
|
|
2927
|
+
|
|
2928
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
2929
|
+
try {
|
|
2930
|
+
const input = cfnSchema.parse(raw);
|
|
2931
|
+
const regionFlag = input.region ? `--region ${input.region}` : '';
|
|
2932
|
+
const stack = input.stack_name ?? '';
|
|
2933
|
+
|
|
2934
|
+
let command: string;
|
|
2935
|
+
|
|
2936
|
+
if (input.provider === 'cdk') {
|
|
2937
|
+
switch (input.action) {
|
|
2938
|
+
case 'list':
|
|
2939
|
+
command = `cdk list`;
|
|
2940
|
+
break;
|
|
2941
|
+
case 'diff':
|
|
2942
|
+
command = `cdk diff ${stack}`;
|
|
2943
|
+
break;
|
|
2944
|
+
case 'deploy':
|
|
2945
|
+
command = `cdk deploy ${stack} --require-approval never`;
|
|
2946
|
+
break;
|
|
2947
|
+
case 'delete':
|
|
2948
|
+
command = `cdk destroy ${stack} --force`;
|
|
2949
|
+
break;
|
|
2950
|
+
default:
|
|
2951
|
+
return err(`CDK does not support action: ${input.action}. Use deploy, diff, list, or delete.`);
|
|
2952
|
+
}
|
|
2953
|
+
} else {
|
|
2954
|
+
switch (input.action) {
|
|
2955
|
+
case 'list':
|
|
2956
|
+
command = `aws cloudformation list-stacks --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE ${regionFlag} --output json`;
|
|
2957
|
+
break;
|
|
2958
|
+
case 'describe':
|
|
2959
|
+
command = `aws cloudformation describe-stacks --stack-name ${stack} ${regionFlag} --output json`;
|
|
2960
|
+
break;
|
|
2961
|
+
case 'create':
|
|
2962
|
+
command = `aws cloudformation create-stack --stack-name ${stack} --template-body file://${input.template ?? 'template.yaml'} ${input.parameters ? `--parameters ${input.parameters}` : ''} ${regionFlag}`;
|
|
2963
|
+
break;
|
|
2964
|
+
case 'update':
|
|
2965
|
+
command = `aws cloudformation update-stack --stack-name ${stack} --template-body file://${input.template ?? 'template.yaml'} ${input.parameters ? `--parameters ${input.parameters}` : ''} ${regionFlag}`;
|
|
2966
|
+
break;
|
|
2967
|
+
case 'delete':
|
|
2968
|
+
command = `aws cloudformation delete-stack --stack-name ${stack} ${regionFlag}`;
|
|
2969
|
+
break;
|
|
2970
|
+
case 'validate':
|
|
2971
|
+
command = `aws cloudformation validate-template --template-body file://${input.template ?? 'template.yaml'} ${regionFlag}`;
|
|
2972
|
+
break;
|
|
2973
|
+
case 'events':
|
|
2974
|
+
command = `aws cloudformation describe-stack-events --stack-name ${stack} ${regionFlag} --output json`;
|
|
2975
|
+
break;
|
|
2976
|
+
case 'drift':
|
|
2977
|
+
command = `aws cloudformation detect-stack-drift --stack-name ${stack} ${regionFlag} --output json`;
|
|
2978
|
+
break;
|
|
2979
|
+
case 'deploy':
|
|
2980
|
+
command = `aws cloudformation deploy --stack-name ${stack} --template-file ${input.template ?? 'template.yaml'} ${input.parameters ? `--parameter-overrides ${input.parameters}` : ''} ${regionFlag}`;
|
|
2981
|
+
break;
|
|
2982
|
+
case 'diff':
|
|
2983
|
+
command = `aws cloudformation get-template --stack-name ${stack} ${regionFlag} --output json`;
|
|
2984
|
+
break;
|
|
2985
|
+
default:
|
|
2986
|
+
return err(`Unknown CloudFormation action: ${input.action}`);
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
const { stdout, stderr } = await execAsync(command!, {
|
|
2991
|
+
timeout: 300_000,
|
|
2992
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
2993
|
+
});
|
|
2994
|
+
|
|
2995
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
2996
|
+
return ok(combined || '(success)');
|
|
2997
|
+
} catch (error: unknown) {
|
|
2998
|
+
return err(`CloudFormation/CDK operation failed: ${errorMessage(error)}`);
|
|
2999
|
+
}
|
|
3000
|
+
},
|
|
3001
|
+
};
|
|
3002
|
+
|
|
3003
|
+
// ---------------------------------------------------------------------------
|
|
3004
|
+
// 23. k8s_rbac (Kubernetes RBAC management)
|
|
3005
|
+
// ---------------------------------------------------------------------------
|
|
3006
|
+
|
|
3007
|
+
const k8sRbacSchema = z.object({
|
|
3008
|
+
action: z.enum(['list','get','create','delete','bind','unbind','audit','who-can'])
|
|
3009
|
+
.describe('RBAC action to perform'),
|
|
3010
|
+
resource_type: z.enum(['serviceaccount','role','clusterrole','rolebinding','clusterrolebinding'])
|
|
3011
|
+
.optional()
|
|
3012
|
+
.describe('RBAC resource type'),
|
|
3013
|
+
name: z.string().optional().describe('Resource name'),
|
|
3014
|
+
namespace: z.string().optional().describe('Kubernetes namespace'),
|
|
3015
|
+
subject: z.string().optional().describe('Subject (user, group, or serviceaccount)'),
|
|
3016
|
+
verb: z.string().optional().describe('Verb for who-can checks (get, list, create, delete, etc.)'),
|
|
3017
|
+
});
|
|
3018
|
+
|
|
3019
|
+
export const k8sRbacTool: ToolDefinition = {
|
|
3020
|
+
name: 'k8s_rbac',
|
|
3021
|
+
description: 'Manage Kubernetes RBAC: ServiceAccounts, Roles, ClusterRoles, RoleBindings. Audit permissions and check access.',
|
|
3022
|
+
inputSchema: k8sRbacSchema,
|
|
3023
|
+
permissionTier: 'ask_once',
|
|
3024
|
+
category: 'devops',
|
|
3025
|
+
isDestructive: true,
|
|
3026
|
+
|
|
3027
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
3028
|
+
try {
|
|
3029
|
+
const input = k8sRbacSchema.parse(raw);
|
|
3030
|
+
const nsFlag = input.namespace ? `-n ${input.namespace}` : '';
|
|
3031
|
+
const resType = input.resource_type ?? 'role';
|
|
3032
|
+
|
|
3033
|
+
let command: string;
|
|
3034
|
+
|
|
3035
|
+
switch (input.action) {
|
|
3036
|
+
case 'list':
|
|
3037
|
+
command = `kubectl get ${resType} ${nsFlag} -o wide`;
|
|
3038
|
+
break;
|
|
3039
|
+
case 'get':
|
|
3040
|
+
command = `kubectl describe ${resType} ${input.name ?? ''} ${nsFlag}`;
|
|
3041
|
+
break;
|
|
3042
|
+
case 'audit':
|
|
3043
|
+
command = `kubectl auth can-i --list ${nsFlag}`;
|
|
3044
|
+
break;
|
|
3045
|
+
case 'who-can':
|
|
3046
|
+
if (!input.verb || !input.name) {
|
|
3047
|
+
return err('verb and name (resource) are required for who-can checks');
|
|
3048
|
+
}
|
|
3049
|
+
command = `kubectl who-can ${input.verb} ${input.name} ${nsFlag}`;
|
|
3050
|
+
break;
|
|
3051
|
+
case 'create':
|
|
3052
|
+
if (resType === 'serviceaccount') {
|
|
3053
|
+
command = `kubectl create serviceaccount ${input.name ?? ''} ${nsFlag}`;
|
|
3054
|
+
} else {
|
|
3055
|
+
return err('For create: use kubectl with a manifest file for roles and bindings');
|
|
3056
|
+
}
|
|
3057
|
+
break;
|
|
3058
|
+
case 'bind':
|
|
3059
|
+
if (!input.subject || !input.name) {
|
|
3060
|
+
return err('subject and name (role) are required for bind action');
|
|
3061
|
+
}
|
|
3062
|
+
command = `kubectl create rolebinding ${input.subject}-binding --${resType === 'clusterrole' ? 'clusterrole' : 'role'}=${input.name} --user=${input.subject} ${nsFlag}`;
|
|
3063
|
+
break;
|
|
3064
|
+
case 'unbind':
|
|
3065
|
+
command = `kubectl delete rolebinding ${input.name ?? ''} ${nsFlag}`;
|
|
3066
|
+
break;
|
|
3067
|
+
case 'delete':
|
|
3068
|
+
command = `kubectl delete ${resType} ${input.name ?? ''} ${nsFlag}`;
|
|
3069
|
+
break;
|
|
3070
|
+
default:
|
|
3071
|
+
return err(`Unknown RBAC action: ${input.action}`);
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
// Warn on wildcard rules before create/bind
|
|
3075
|
+
if (['create', 'bind'].includes(input.action) && input.name === '*') {
|
|
3076
|
+
return err('SAFETY CHECK: Wildcard (*) resource names in RBAC grant excessive permissions. Use specific resource names instead.');
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
const { stdout, stderr } = await execAsync(command!, {
|
|
3080
|
+
timeout: 30_000,
|
|
3081
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
3082
|
+
});
|
|
3083
|
+
|
|
3084
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
3085
|
+
return ok(combined || '(success)');
|
|
3086
|
+
} catch (error: unknown) {
|
|
3087
|
+
return err(`K8s RBAC operation failed: ${errorMessage(error)}`);
|
|
3088
|
+
}
|
|
3089
|
+
},
|
|
3090
|
+
};
|
|
3091
|
+
|
|
3092
|
+
// ---------------------------------------------------------------------------
|
|
3093
|
+
// aws, gcloud, az — Cloud CLI tools (M5)
|
|
3094
|
+
// ---------------------------------------------------------------------------
|
|
3095
|
+
|
|
3096
|
+
const awsSchema = z.object({
|
|
3097
|
+
service: z.string().describe('AWS service (e.g., "ec2", "s3", "iam", "ecs", "eks")'),
|
|
3098
|
+
action: z.string().describe('Service action (e.g., "describe-instances", "list-buckets")'),
|
|
3099
|
+
args: z.string().optional().describe('Additional CLI arguments'),
|
|
3100
|
+
profile: z.string().optional().describe('AWS profile name (overrides AWS_PROFILE)'),
|
|
3101
|
+
region: z.string().optional().describe('AWS region (overrides AWS_DEFAULT_REGION)'),
|
|
3102
|
+
output: z.enum(['json', 'text', 'table']).optional().default('json').describe('Output format'),
|
|
3103
|
+
});
|
|
3104
|
+
|
|
3105
|
+
export const awsTool: ToolDefinition = {
|
|
3106
|
+
name: 'aws',
|
|
3107
|
+
description: 'Execute AWS CLI commands. Use for cloud resource management, IAM, EC2, S3, EKS, RDS, and all AWS services. Prefer this over bash for AWS operations.',
|
|
3108
|
+
inputSchema: awsSchema,
|
|
3109
|
+
permissionTier: 'ask_once',
|
|
3110
|
+
category: 'devops',
|
|
3111
|
+
|
|
3112
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
3113
|
+
try {
|
|
3114
|
+
const input = awsSchema.parse(raw);
|
|
3115
|
+
const parts = ['aws', input.service, input.action];
|
|
3116
|
+
if (input.profile) parts.push('--profile', input.profile);
|
|
3117
|
+
else if (process.env.AWS_PROFILE) parts.push('--profile', process.env.AWS_PROFILE);
|
|
3118
|
+
if (input.region) parts.push('--region', input.region);
|
|
3119
|
+
parts.push('--output', input.output ?? 'json');
|
|
3120
|
+
if (input.args) parts.push(input.args);
|
|
3121
|
+
const command = parts.join(' ');
|
|
3122
|
+
const env = { ...process.env } as NodeJS.ProcessEnv;
|
|
3123
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
3124
|
+
timeout: 60_000,
|
|
3125
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
3126
|
+
env,
|
|
3127
|
+
});
|
|
3128
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
3129
|
+
return ok(combined || '(no output)');
|
|
3130
|
+
} catch (error: unknown) {
|
|
3131
|
+
return err(`AWS CLI failed: ${errorMessage(error)}`);
|
|
3132
|
+
}
|
|
3133
|
+
},
|
|
3134
|
+
};
|
|
3135
|
+
|
|
3136
|
+
const gcloudSchema = z.object({
|
|
3137
|
+
service: z.string().describe('GCP service group (e.g., "compute", "container", "sql", "storage")'),
|
|
3138
|
+
action: z.string().describe('Service action (e.g., "instances list", "clusters get-credentials")'),
|
|
3139
|
+
args: z.string().optional().describe('Additional CLI arguments'),
|
|
3140
|
+
project: z.string().optional().describe('GCP project ID'),
|
|
3141
|
+
region: z.string().optional().describe('GCP region'),
|
|
3142
|
+
output: z.enum(['json', 'yaml', 'text', 'table']).optional().default('json').describe('Output format'),
|
|
3143
|
+
});
|
|
3144
|
+
|
|
3145
|
+
export const gcloudTool: ToolDefinition = {
|
|
3146
|
+
name: 'gcloud',
|
|
3147
|
+
description: 'Execute Google Cloud CLI (gcloud) commands. Use for GCP resource management, GKE, Cloud SQL, GCS, and all GCP services. Prefer this over bash for GCP operations.',
|
|
3148
|
+
inputSchema: gcloudSchema,
|
|
3149
|
+
permissionTier: 'ask_once',
|
|
3150
|
+
category: 'devops',
|
|
3151
|
+
|
|
3152
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
3153
|
+
try {
|
|
3154
|
+
const input = gcloudSchema.parse(raw);
|
|
3155
|
+
const parts = ['gcloud', input.service, input.action];
|
|
3156
|
+
if (input.project) parts.push('--project', input.project);
|
|
3157
|
+
if (input.region) parts.push('--region', input.region);
|
|
3158
|
+
parts.push('--format', input.output ?? 'json');
|
|
3159
|
+
if (input.args) parts.push(input.args);
|
|
3160
|
+
const command = parts.join(' ');
|
|
3161
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
3162
|
+
timeout: 60_000,
|
|
3163
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
3164
|
+
});
|
|
3165
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
3166
|
+
return ok(combined || '(no output)');
|
|
3167
|
+
} catch (error: unknown) {
|
|
3168
|
+
return err(`gcloud CLI failed: ${errorMessage(error)}`);
|
|
3169
|
+
}
|
|
3170
|
+
},
|
|
3171
|
+
};
|
|
3172
|
+
|
|
3173
|
+
const azSchema = z.object({
|
|
3174
|
+
service: z.string().describe('Azure service group (e.g., "vm", "aks", "storage", "sql", "network")'),
|
|
3175
|
+
action: z.string().describe('Service action (e.g., "list", "show", "create", "delete")'),
|
|
3176
|
+
args: z.string().optional().describe('Additional CLI arguments'),
|
|
3177
|
+
subscription: z.string().optional().describe('Azure subscription ID'),
|
|
3178
|
+
resource_group: z.string().optional().describe('Azure resource group'),
|
|
3179
|
+
output: z.enum(['json', 'yaml', 'table', 'tsv']).optional().default('json').describe('Output format'),
|
|
3180
|
+
});
|
|
3181
|
+
|
|
3182
|
+
export const azTool: ToolDefinition = {
|
|
3183
|
+
name: 'az',
|
|
3184
|
+
description: 'Execute Azure CLI (az) commands. Use for Azure resource management, AKS, Azure SQL, Storage, and all Azure services. Prefer this over bash for Azure operations.',
|
|
3185
|
+
inputSchema: azSchema,
|
|
3186
|
+
permissionTier: 'ask_once',
|
|
3187
|
+
category: 'devops',
|
|
3188
|
+
|
|
3189
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
3190
|
+
try {
|
|
3191
|
+
const input = azSchema.parse(raw);
|
|
3192
|
+
const parts = ['az', input.service, input.action];
|
|
3193
|
+
if (input.subscription) parts.push('--subscription', input.subscription);
|
|
3194
|
+
if (input.resource_group) parts.push('--resource-group', input.resource_group);
|
|
3195
|
+
parts.push('--output', input.output ?? 'json');
|
|
3196
|
+
if (input.args) parts.push(input.args);
|
|
3197
|
+
const command = parts.join(' ');
|
|
3198
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
3199
|
+
timeout: 60_000,
|
|
3200
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
3201
|
+
});
|
|
3202
|
+
const combined = [stdout, stderr].filter(Boolean).join('\n');
|
|
3203
|
+
return ok(combined || '(no output)');
|
|
3204
|
+
} catch (error: unknown) {
|
|
3205
|
+
return err(`az CLI failed: ${errorMessage(error)}`);
|
|
3206
|
+
}
|
|
3207
|
+
},
|
|
3208
|
+
};
|
|
3209
|
+
|
|
3210
|
+
// ---------------------------------------------------------------------------
|
|
3211
|
+
// 27. incident
|
|
3212
|
+
// ---------------------------------------------------------------------------
|
|
3213
|
+
|
|
3214
|
+
const incidentSchema = z.object({
|
|
3215
|
+
provider: z.enum(['pagerduty', 'opsgenie']).describe('Incident management provider'),
|
|
3216
|
+
action: z.enum(['list', 'get', 'acknowledge', 'resolve', 'create', 'on-call']).describe('Action to perform'),
|
|
3217
|
+
id: z.string().optional().describe('Incident/alert ID for get/acknowledge/resolve'),
|
|
3218
|
+
title: z.string().optional().describe('Title for create action'),
|
|
3219
|
+
body: z.string().optional().describe('Description for create action'),
|
|
3220
|
+
urgency: z.enum(['high', 'low']).optional().describe('Urgency for create action (PagerDuty)'),
|
|
3221
|
+
service_id: z.string().optional().describe('Service ID for create action (PagerDuty)'),
|
|
3222
|
+
team_id: z.string().optional().describe('Team ID for Opsgenie alerts'),
|
|
3223
|
+
status: z.enum(['triggered', 'acknowledged', 'resolved']).optional().describe('Filter by status for list action'),
|
|
3224
|
+
});
|
|
3225
|
+
|
|
3226
|
+
export const incidentTool: ToolDefinition = {
|
|
3227
|
+
name: 'incident',
|
|
3228
|
+
description: 'Manage incidents and alerts via PagerDuty or Opsgenie — list, acknowledge, resolve, and create incidents',
|
|
3229
|
+
category: 'devops',
|
|
3230
|
+
permissionTier: 'ask_once',
|
|
3231
|
+
inputSchema: incidentSchema,
|
|
3232
|
+
execute: async (rawInput) => {
|
|
3233
|
+
const input = rawInput as z.infer<typeof incidentSchema>;
|
|
3234
|
+
const { provider, action, id, title, body, urgency, service_id, team_id, status } = input;
|
|
3235
|
+
|
|
3236
|
+
if (provider === 'pagerduty') {
|
|
3237
|
+
const apiKey = process.env.PD_API_KEY || process.env.PAGERDUTY_API_KEY;
|
|
3238
|
+
if (!apiKey) {
|
|
3239
|
+
return err('PagerDuty API key not found. Set PD_API_KEY or PAGERDUTY_API_KEY environment variable.');
|
|
3240
|
+
}
|
|
3241
|
+
const baseUrl = 'https://api.pagerduty.com';
|
|
3242
|
+
const headers = { 'Authorization': `Token token=${apiKey}`, 'Accept': 'application/vnd.pagerduty+json;version=2', 'Content-Type': 'application/json' };
|
|
3243
|
+
|
|
3244
|
+
try {
|
|
3245
|
+
if (action === 'list') {
|
|
3246
|
+
const params = new URLSearchParams();
|
|
3247
|
+
if (status) params.set('statuses[]', status);
|
|
3248
|
+
params.set('limit', '20');
|
|
3249
|
+
const res = await fetch(`${baseUrl}/incidents?${params}`, { headers });
|
|
3250
|
+
if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
|
|
3251
|
+
const data = await res.json() as { incidents: Array<{ id: string; title: string; status: string; urgency: string; created_at: string }> };
|
|
3252
|
+
if (!data.incidents.length) return ok('No incidents found.');
|
|
3253
|
+
return ok(data.incidents.map(i => `[${i.status.toUpperCase()}] ${i.id}: ${i.title} (${i.urgency}) — ${i.created_at}`).join('\n'));
|
|
3254
|
+
}
|
|
3255
|
+
if (action === 'get' && id) {
|
|
3256
|
+
const res = await fetch(`${baseUrl}/incidents/${id}`, { headers });
|
|
3257
|
+
if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
|
|
3258
|
+
const data = await res.json() as { incident: { id: string; title: string; status: string; urgency: string; body?: { details?: string }; created_at: string } };
|
|
3259
|
+
const inc = data.incident;
|
|
3260
|
+
return ok(`ID: ${inc.id}\nTitle: ${inc.title}\nStatus: ${inc.status}\nUrgency: ${inc.urgency}\nCreated: ${inc.created_at}\n${inc.body?.details ? `Details: ${inc.body.details}` : ''}`);
|
|
3261
|
+
}
|
|
3262
|
+
if (action === 'acknowledge' && id) {
|
|
3263
|
+
const res = await fetch(`${baseUrl}/incidents/${id}`, {
|
|
3264
|
+
method: 'PUT', headers,
|
|
3265
|
+
body: JSON.stringify({ incident: { type: 'incident_reference', status: 'acknowledged' } }),
|
|
3266
|
+
});
|
|
3267
|
+
if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
|
|
3268
|
+
return ok(`Incident ${id} acknowledged.`);
|
|
3269
|
+
}
|
|
3270
|
+
if (action === 'resolve' && id) {
|
|
3271
|
+
const res = await fetch(`${baseUrl}/incidents/${id}`, {
|
|
3272
|
+
method: 'PUT', headers,
|
|
3273
|
+
body: JSON.stringify({ incident: { type: 'incident_reference', status: 'resolved' } }),
|
|
3274
|
+
});
|
|
3275
|
+
if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
|
|
3276
|
+
return ok(`Incident ${id} resolved.`);
|
|
3277
|
+
}
|
|
3278
|
+
if (action === 'create') {
|
|
3279
|
+
if (!title || !service_id) return err('create action requires title and service_id');
|
|
3280
|
+
const res = await fetch(`${baseUrl}/incidents`, {
|
|
3281
|
+
method: 'POST', headers,
|
|
3282
|
+
body: JSON.stringify({ incident: { type: 'incident', title, urgency: urgency ?? 'high', service: { id: service_id, type: 'service_reference' }, body: body ? { type: 'incident_body', details: body } : undefined } }),
|
|
3283
|
+
});
|
|
3284
|
+
if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
|
|
3285
|
+
const data = await res.json() as { incident: { id: string } };
|
|
3286
|
+
return ok(`Incident created: ${data.incident.id}`);
|
|
3287
|
+
}
|
|
3288
|
+
if (action === 'on-call') {
|
|
3289
|
+
const res = await fetch(`${baseUrl}/oncalls?limit=10`, { headers });
|
|
3290
|
+
if (!res.ok) return err(`PagerDuty API error: ${res.status} ${res.statusText}`);
|
|
3291
|
+
const data = await res.json() as { oncalls: Array<{ user: { summary: string }; schedule?: { summary?: string }; start: string; end: string }> };
|
|
3292
|
+
return ok(data.oncalls.map(o => `${o.user.summary}${o.schedule?.summary ? ` (${o.schedule.summary})` : ''} until ${o.end}`).join('\n') || 'No on-call data found.');
|
|
3293
|
+
}
|
|
3294
|
+
return err(`Unknown action: ${action}`);
|
|
3295
|
+
} catch (e) {
|
|
3296
|
+
return err(errorMessage(e));
|
|
3297
|
+
}
|
|
3298
|
+
} else {
|
|
3299
|
+
// Opsgenie
|
|
3300
|
+
const apiKey = process.env.OPSGENIE_API_KEY;
|
|
3301
|
+
if (!apiKey) {
|
|
3302
|
+
return err('Opsgenie API key not found. Set OPSGENIE_API_KEY environment variable.');
|
|
3303
|
+
}
|
|
3304
|
+
const baseUrl = 'https://api.opsgenie.com/v2';
|
|
3305
|
+
const headers = { 'Authorization': `GenieKey ${apiKey}`, 'Content-Type': 'application/json' };
|
|
3306
|
+
|
|
3307
|
+
try {
|
|
3308
|
+
if (action === 'list') {
|
|
3309
|
+
const params = new URLSearchParams({ limit: '20', sort: 'createdAt', order: 'desc' });
|
|
3310
|
+
if (status) params.set('query', `status=${status}`);
|
|
3311
|
+
const res = await fetch(`${baseUrl}/alerts?${params}`, { headers });
|
|
3312
|
+
if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
|
|
3313
|
+
const data = await res.json() as { data: Array<{ id: string; tinyId: string; message: string; status: string; priority: string; createdAt: string }> };
|
|
3314
|
+
if (!data.data.length) return ok('No alerts found.');
|
|
3315
|
+
return ok(data.data.map(a => `[${a.status.toUpperCase()}] ${a.tinyId}: ${a.message} (${a.priority}) — ${a.createdAt}`).join('\n'));
|
|
3316
|
+
}
|
|
3317
|
+
if (action === 'get' && id) {
|
|
3318
|
+
const res = await fetch(`${baseUrl}/alerts/${id}`, { headers });
|
|
3319
|
+
if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
|
|
3320
|
+
const data = await res.json() as { data: { id: string; message: string; status: string; priority: string; description?: string; createdAt: string } };
|
|
3321
|
+
const a = data.data;
|
|
3322
|
+
return ok(`ID: ${a.id}\nMessage: ${a.message}\nStatus: ${a.status}\nPriority: ${a.priority}\nCreated: ${a.createdAt}\n${a.description ? `Description: ${a.description}` : ''}`);
|
|
3323
|
+
}
|
|
3324
|
+
if (action === 'acknowledge' && id) {
|
|
3325
|
+
const res = await fetch(`${baseUrl}/alerts/${id}/acknowledge`, {
|
|
3326
|
+
method: 'POST', headers, body: JSON.stringify({ note: 'Acknowledged via Nimbus' }),
|
|
3327
|
+
});
|
|
3328
|
+
if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
|
|
3329
|
+
return ok(`Alert ${id} acknowledged.`);
|
|
3330
|
+
}
|
|
3331
|
+
if (action === 'resolve' && id) {
|
|
3332
|
+
const res = await fetch(`${baseUrl}/alerts/${id}/close`, {
|
|
3333
|
+
method: 'POST', headers, body: JSON.stringify({ note: 'Resolved via Nimbus' }),
|
|
3334
|
+
});
|
|
3335
|
+
if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
|
|
3336
|
+
return ok(`Alert ${id} resolved.`);
|
|
3337
|
+
}
|
|
3338
|
+
if (action === 'create') {
|
|
3339
|
+
if (!title) return err('create action requires title');
|
|
3340
|
+
const res = await fetch(`${baseUrl}/alerts`, {
|
|
3341
|
+
method: 'POST', headers,
|
|
3342
|
+
body: JSON.stringify({ message: title, description: body, priority: urgency === 'high' ? 'P1' : 'P3', teams: team_id ? [{ id: team_id }] : undefined }),
|
|
3343
|
+
});
|
|
3344
|
+
if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
|
|
3345
|
+
const data = await res.json() as { requestId: string };
|
|
3346
|
+
return ok(`Alert created. Request ID: ${data.requestId}`);
|
|
3347
|
+
}
|
|
3348
|
+
if (action === 'on-call') {
|
|
3349
|
+
const res = await fetch(`${baseUrl}/schedules/on-calls`, { headers });
|
|
3350
|
+
if (!res.ok) return err(`Opsgenie API error: ${res.status} ${res.statusText}`);
|
|
3351
|
+
const data = await res.json() as { data: Array<{ _parent?: { name?: string }; onCallParticipants: Array<{ name: string }> }> };
|
|
3352
|
+
return ok(data.data.map(s => `${s._parent?.name}: ${s.onCallParticipants.map((p) => p.name).join(', ')}`).join('\n') || 'No on-call data.');
|
|
3353
|
+
}
|
|
3354
|
+
return err(`Unknown action: ${action}`);
|
|
3355
|
+
} catch (e) {
|
|
3356
|
+
return err(errorMessage(e));
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
},
|
|
3360
|
+
};
|
|
3361
|
+
|
|
3362
|
+
// ---------------------------------------------------------------------------
|
|
3363
|
+
// 28. generate_infra (IaC generation from natural language)
|
|
3364
|
+
// ---------------------------------------------------------------------------
|
|
3365
|
+
|
|
3366
|
+
const generateInfraSchema = z.object({
|
|
3367
|
+
type: z.enum(['terraform', 'kubernetes', 'helm'])
|
|
3368
|
+
.describe('Type of infrastructure to generate'),
|
|
3369
|
+
intent: z.string().describe('Natural language description of what to generate'),
|
|
3370
|
+
provider: z.enum(['aws', 'gcp', 'azure']).optional()
|
|
3371
|
+
.describe('Cloud provider (for terraform generation)'),
|
|
3372
|
+
outputDir: z.string().optional()
|
|
3373
|
+
.describe('Directory to write generated files to (default: ./generated/)'),
|
|
3374
|
+
});
|
|
3375
|
+
|
|
3376
|
+
export const generateInfraTool: ToolDefinition = {
|
|
3377
|
+
name: 'generate_infra',
|
|
3378
|
+
description: 'Generate infrastructure as code (Terraform, Kubernetes manifests, or Helm charts) from natural language descriptions. Writes files to outputDir.',
|
|
3379
|
+
inputSchema: generateInfraSchema,
|
|
3380
|
+
permissionTier: 'ask_once',
|
|
3381
|
+
category: 'devops',
|
|
3382
|
+
|
|
3383
|
+
async execute(raw: unknown): Promise<ToolResult> {
|
|
3384
|
+
try {
|
|
3385
|
+
const input = generateInfraSchema.parse(raw);
|
|
3386
|
+
const { mkdirSync, writeFileSync } = await import('node:fs');
|
|
3387
|
+
const { join } = await import('node:path');
|
|
3388
|
+
const outputDir = input.outputDir ?? './generated';
|
|
3389
|
+
|
|
3390
|
+
mkdirSync(outputDir, { recursive: true });
|
|
3391
|
+
|
|
3392
|
+
if (input.type === 'terraform') {
|
|
3393
|
+
const { TerraformProjectGenerator } = await import('../../generator');
|
|
3394
|
+
const provider = input.provider ?? 'aws';
|
|
3395
|
+
const generator = new TerraformProjectGenerator();
|
|
3396
|
+
const projectName = input.intent.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 32) || 'nimbus-infra';
|
|
3397
|
+
const project = await generator.generate({
|
|
3398
|
+
projectName,
|
|
3399
|
+
provider,
|
|
3400
|
+
region: provider === 'aws' ? 'us-east-1' : provider === 'gcp' ? 'us-central1' : 'eastus',
|
|
3401
|
+
components: [],
|
|
3402
|
+
});
|
|
3403
|
+
const files: string[] = [];
|
|
3404
|
+
for (const file of project.files) {
|
|
3405
|
+
const parts = file.path.split('/').slice(0, -1).join('/');
|
|
3406
|
+
if (parts) mkdirSync(join(outputDir, parts), { recursive: true });
|
|
3407
|
+
const filePath = join(outputDir, file.path);
|
|
3408
|
+
writeFileSync(filePath, file.content, 'utf-8');
|
|
3409
|
+
files.push(file.path);
|
|
3410
|
+
}
|
|
3411
|
+
return ok(`Generated ${files.length} Terraform files in ${outputDir}:\n${files.join('\n')}`);
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
if (input.type === 'kubernetes') {
|
|
3415
|
+
const { createKubernetesGenerator } = await import('../../generator');
|
|
3416
|
+
const appName = input.intent.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 32) || 'app';
|
|
3417
|
+
const generator = createKubernetesGenerator({
|
|
3418
|
+
appName,
|
|
3419
|
+
namespace: 'default',
|
|
3420
|
+
workloadType: 'deployment',
|
|
3421
|
+
image: `${appName}:latest`,
|
|
3422
|
+
replicas: 2,
|
|
3423
|
+
containerPort: 8080,
|
|
3424
|
+
resources: { requests: { cpu: '100m', memory: '128Mi' }, limits: { cpu: '500m', memory: '512Mi' } },
|
|
3425
|
+
});
|
|
3426
|
+
const manifests = generator.generate();
|
|
3427
|
+
const files: string[] = [];
|
|
3428
|
+
for (const manifest of manifests) {
|
|
3429
|
+
const filename = `${manifest.kind.toLowerCase()}-${manifest.name}.yaml`;
|
|
3430
|
+
const filePath = join(outputDir, filename);
|
|
3431
|
+
writeFileSync(filePath, manifest.content, 'utf-8');
|
|
3432
|
+
files.push(filename);
|
|
3433
|
+
}
|
|
3434
|
+
return ok(`Generated ${files.length} Kubernetes manifests in ${outputDir}:\n${files.join('\n')}`);
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
if (input.type === 'helm') {
|
|
3438
|
+
const { createHelmGenerator } = await import('../../generator');
|
|
3439
|
+
const chartName = input.intent.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 32) || 'my-chart';
|
|
3440
|
+
const generator = createHelmGenerator({
|
|
3441
|
+
name: chartName,
|
|
3442
|
+
description: input.intent,
|
|
3443
|
+
version: '0.1.0',
|
|
3444
|
+
appVersion: '1.0.0',
|
|
3445
|
+
values: {
|
|
3446
|
+
image: { repository: chartName, tag: 'latest' },
|
|
3447
|
+
},
|
|
3448
|
+
});
|
|
3449
|
+
const chartFiles = generator.generate();
|
|
3450
|
+
const files: string[] = [];
|
|
3451
|
+
for (const file of chartFiles) {
|
|
3452
|
+
const parts = file.path.split('/').slice(0, -1).join('/');
|
|
3453
|
+
if (parts) mkdirSync(join(outputDir, parts), { recursive: true });
|
|
3454
|
+
const filePath = join(outputDir, file.path);
|
|
3455
|
+
writeFileSync(filePath, file.content, 'utf-8');
|
|
3456
|
+
files.push(file.path);
|
|
3457
|
+
}
|
|
3458
|
+
return ok(`Generated Helm chart in ${outputDir}:\n${files.join('\n')}`);
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
return err(`Unknown type: ${input.type}`);
|
|
3462
|
+
} catch (error: unknown) {
|
|
3463
|
+
return err(`Infrastructure generation failed: ${errorMessage(error)}`);
|
|
3464
|
+
}
|
|
3465
|
+
},
|
|
3466
|
+
};
|
|
3467
|
+
|
|
597
3468
|
// ---------------------------------------------------------------------------
|
|
598
3469
|
// Aggregate export
|
|
599
3470
|
// ---------------------------------------------------------------------------
|
|
600
3471
|
|
|
601
|
-
/** All
|
|
3472
|
+
/** All 28 DevOps tools as an ordered array. */
|
|
602
3473
|
export const devopsTools: ToolDefinition[] = [
|
|
603
3474
|
terraformTool,
|
|
604
3475
|
kubectlTool,
|
|
@@ -607,6 +3478,25 @@ export const devopsTools: ToolDefinition[] = [
|
|
|
607
3478
|
costEstimateTool,
|
|
608
3479
|
driftDetectTool,
|
|
609
3480
|
deployPreviewTool,
|
|
3481
|
+
terraformPlanAnalyzeTool,
|
|
3482
|
+
kubectlContextTool,
|
|
3483
|
+
helmValuesTool,
|
|
610
3484
|
gitTool,
|
|
611
3485
|
taskTool,
|
|
3486
|
+
dockerTool,
|
|
3487
|
+
secretsTool,
|
|
3488
|
+
cicdTool,
|
|
3489
|
+
monitorTool,
|
|
3490
|
+
gitopsTool,
|
|
3491
|
+
cloudActionTool,
|
|
3492
|
+
logsTool,
|
|
3493
|
+
certsTool,
|
|
3494
|
+
meshTool,
|
|
3495
|
+
cfnTool,
|
|
3496
|
+
k8sRbacTool,
|
|
3497
|
+
awsTool,
|
|
3498
|
+
gcloudTool,
|
|
3499
|
+
azTool,
|
|
3500
|
+
incidentTool,
|
|
3501
|
+
generateInfraTool,
|
|
612
3502
|
];
|