Compare commits

...

28 Commits

Author SHA1 Message Date
AlexsJones
a98de9a821 feat: support many-to-one auth provider mapping
This commit enhances the AI provider configuration system to support multiple
configurations per provider while maintaining backward compatibility. Key changes:

- Add GetConfigName() to IAIConfig interface to support named configurations
- Update AIProvider struct to handle multiple configurations via Configs array
- Implement configuration fallback logic in AIProvider methods
- Add support for default configuration selection
- Update mock configuration in tests to implement new interface methods

The changes allow providers to have multiple named configurations while
preserving existing functionality for single-configuration setups. This
enables more flexible provider configuration management and better
integration with various AI backends.

Breaking Changes: None
Backward Compatible: Yes

Signed-off-by: AlexsJones <alexsimonjones@gmail.com>
2025-05-06 11:59:09 +01:00
AlexsJones
a1a405a380 feat: support many:1 auth:provider mapping 2025-05-06 11:51:13 +01:00
renovate[bot]
6a81d2c140 fix(deps): update k8s.io/utils digest to 0f33e8f (#1484)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 11:30:48 +01:00
rkarthikr
21bc76e5b7 feat: add support for Amazon Bedrock Inference Profiles (#1492)
Signed-off-by: rkarthikr <38294804+rkarthikr@users.noreply.github.com>
Co-authored-by: Alex Jones <alexsimonjones@gmail.com>
2025-05-06 11:18:40 +01:00
renovate[bot]
d5341f3c00 chore(deps): update golangci/golangci-lint-action digest to 9fae48a (#1489)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 09:23:12 +01:00
Alex Jones
752a16c407 feat: supported regions govcloud (#1483)
* feat: added token for goreleaser

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* feat: updated the bedrock supported regions

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

---------

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
2025-05-01 09:01:25 +01:00
renovate[bot]
81da402d46 chore(deps): update docker/build-push-action digest to 14487ce (#1472)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-29 13:58:23 +01:00
github-actions[bot]
f2f25edef7 chore(main): release 0.4.15 (#1477)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-29 12:57:30 +01:00
Alex Jones
85935a46d8 feat: added token for goreleaser (#1476)
Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
2025-04-29 12:49:44 +01:00
github-actions[bot]
a56e663169 chore(main): release 0.4.14 (#1470)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-29 09:26:48 +01:00
Alex Jones
e41ffd80d0 feat: add MCP support (#1471)
* feat: first mcp impl

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: update

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: wip

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: switcheed to stdio transport

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: readme

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* feat: fix the linter 🤖

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* feat: fix the linter 🤖

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* feat(mcp): implement MCP server and handler

- Implement MCP server and handler
- Add MCP server to serve
- Add MCP handler to handle MCP requests
- Add MCP server to serve
- Add MCP handler to handle MCP requests

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* feat: consolidating code duplication

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* feat: added http sse support

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: fixed broken tests

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: updated and fixed linter

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: updated and fixed linter

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: updated the linter issues

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

---------

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
2025-04-29 09:22:44 +01:00
ju187
f603948935 feat: using modelName will calling completion (#1469)
* using modelName will calling completion

Signed-off-by: Tony Chen <tony_chen@discovery.com>

* sign

Signed-off-by: Tony Chen <tony_chen@discovery.com>

---------

Signed-off-by: Tony Chen <tony_chen@discovery.com>
2025-04-24 09:15:17 +01:00
github-actions[bot]
67f5855695 chore(main): release 0.4.13 (#1465)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-22 11:28:20 +01:00
Antoine Deschênes
ebb0373f69 fix: reverse hpa ScalingLimited error condition (#1366)
* fix: reverse hpa ScalingLimited error condition

Signed-off-by: Antoine Deschênes <antoine.deschenes@linux.com>

* chore: removed break

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

---------

Signed-off-by: Antoine Deschênes <antoine.deschenes@linux.com>
Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Co-authored-by: Alex Jones <alexsimonjones@gmail.com>
2025-04-22 11:27:02 +01:00
Alex Jones
3b6ad06de1 feat: slack announce (#1466)
* chore: added slack integration on release

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: patched two go dep security warnings

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

---------

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
2025-04-22 10:49:52 +01:00
renovate[bot]
443469960a chore(deps): update softprops/action-gh-release digest to da05d55 (#1464)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-19 20:27:30 +01:00
github-actions[bot]
17863c24d5 chore(main): release 0.4.12 (#1460)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-17 20:57:10 +01:00
renovate[bot]
e588fc316d fix(deps): update module golang.org/x/net to v0.38.0 [security] (#1462)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-17 20:50:44 +01:00
Alex Jones
a128906136 feat: new analyzers (#1459)
* chore: rebased
chore: removed trivy

Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: updated deps

Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* fix: missing error

Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* fix: missing error

Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* feat: switching old sonnet to message API

Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* feat: added three new analyzers

Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(main): release 0.4.2 (#1400)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* docs: remove extra dollar sign in README.md (#1410)

Signed-off-by: Qian_Xiao <heyheyco@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* test: add tests for `k8sgpt/pkg/analyzer/events.go` (#913)

* test: add tests for events_test.go

Signed-off-by: Eshaan Aggarwal <96648934+EshaanAgg@users.noreply.github.com>

* feat: fixed event tests

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

---------

Signed-off-by: Eshaan Aggarwal <96648934+EshaanAgg@users.noreply.github.com>
Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Co-authored-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* docs: add table of contents and cleanup (#1413)

Signed-off-by: hadi2f244 <m.h.azaddel@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: linter (#1414)

* chore: changing linter

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: changing linter

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

* chore: changing linter

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

---------

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(deps): pin golangci/golangci-lint-action action to 1481404 (#1415)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(deps): update goreleaser/goreleaser-action digest to 9c156ee (#1411)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* fix: prometheus UTF8Validation (#1404)

Signed-off-by: Kay Yan <kay.yan@daocloud.io>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* fix(deps): update module gopkg.in/yaml.v2 to v3 (#1363)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: added new AmazonBedrock model  (#1390)

* Update AI Bedrock region - Added mumbai region

Signed-off-by: Sakshi Singh <66963254+sakshirajput02@users.noreply.github.com>

* Update amazonbedrock.go

Signed-off-by: Sakshi Singh <66963254+sakshirajput02@users.noreply.github.com>

* Added new AI model to work for ap-south-1 region[that does not uses inference profile]

Signed-off-by: Sakshi Singh <66963254+sakshirajput02@users.noreply.github.com>

---------

Signed-off-by: Sakshi Singh <66963254+sakshirajput02@users.noreply.github.com>
Co-authored-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(main): release 0.4.3 (#1412)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(deps): update module github.com/docker/docker to v28 (#1376)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: updating deps (#1422)

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(deps): update docker/setup-buildx-action digest to b5ca514 (#1371)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(main): release 0.4.4 (#1421)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: fix workflows (#1423)

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(main): release 0.4.5 (#1424)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: fixing docker build push action (#1426)

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: updated actor for login (#1430)

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(deps): pin docker/build-push-action action to 471d1dc (#1428)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(main): release 0.4.6 (#1427)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: fixing build (#1431)

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(deps): update actions/upload-artifact digest to ea165f8 (#1425)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(main): release 0.4.7 (#1432)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: removed krew release (#1434)

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(main): release 0.4.8 (#1435)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: fixing (#1437)

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(deps): pin dependencies (#1440)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(main): release 0.4.9 (#1439)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* fix: pod analyzer catches errors when containers are in Terminated state (#1438)

Signed-off-by: Guoxun Wei <guwe@microsoft.com>
Co-authored-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* feat: add a naive support of bedrock inference profile (#1446)

* feat: add a naive support of bedrock inference profile

Signed-off-by: Tony Chen <tony_chen@discovery.com>

* feat: improving the tests

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>

---------

Signed-off-by: Tony Chen <tony_chen@discovery.com>
Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Co-authored-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* fix(deps): update module gopkg.in/yaml.v2 to v3 (#1417)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* fix(deps): update module helm.sh/helm/v3 to v3.17.3 [security] (#1448)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(main): release 0.4.10 (#1441)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* feat: call bedrock with inference profile (#1449)

* call bedrock with inference profile

Signed-off-by: Tony Chen <tony_chen@discovery.com>

* add validation and test

Signed-off-by: Tony Chen <tony_chen@discovery.com>

* update test

Signed-off-by: Tony Chen <tony_chen@discovery.com>

---------

Signed-off-by: Tony Chen <tony_chen@discovery.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* fix(deps): update module gopkg.in/yaml.v2 to v3 (#1447)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* docs: fix the slack invite link (#1450)

Signed-off-by: Pengfei Ni <feiskyer@gmail.com>
Co-authored-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* feat: add verbose flag to enable detailed output (#1420)

* feat: add verbose flag to enable detailed output

Signed-off-by: Yicheng <36285652+zyc140345@users.noreply.github.com>

* test: add verbose output tests for analysis.go and root.go

Signed-off-by: Yicheng <36285652+zyc140345@users.noreply.github.com>

---------

Signed-off-by: Yicheng <36285652+zyc140345@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* fix(deps): update module gopkg.in/yaml.v2 to v3 (#1453)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* feat: improved test coverage (#1455)

Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* fix: config ai provider in query (#1457)

Signed-off-by: Guoxun Wei <guwe@microsoft.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore(main): release 0.4.11 (#1451)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: fixed test

Signed-off-by: AlexsJones <alexsimonjones@gmail.com>

* chore: fixed test

---------

Signed-off-by: AlexsJones <alexsimonjones@gmail.com>
Signed-off-by: Qian_Xiao <heyheyco@gmail.com>
Signed-off-by: Eshaan Aggarwal <96648934+EshaanAgg@users.noreply.github.com>
Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
Signed-off-by: hadi2f244 <m.h.azaddel@gmail.com>
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Signed-off-by: Kay Yan <kay.yan@daocloud.io>
Signed-off-by: Sakshi Singh <66963254+sakshirajput02@users.noreply.github.com>
Signed-off-by: Guoxun Wei <guwe@microsoft.com>
Signed-off-by: Tony Chen <tony_chen@discovery.com>
Signed-off-by: Pengfei Ni <feiskyer@gmail.com>
Signed-off-by: Yicheng <36285652+zyc140345@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Qian_Xiao <heyheyco@gmail.com>
Co-authored-by: Eshaan Aggarwal <96648934+EshaanAgg@users.noreply.github.com>
Co-authored-by: Hadi Azaddel <m.h.azaddel@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kay Yan <kay.yan@daocloud.io>
Co-authored-by: Sakshi Singh <66963254+sakshirajput02@users.noreply.github.com>
Co-authored-by: gossion <guwe@microsoft.com>
Co-authored-by: ju187 <tony_chen@discovery.com>
Co-authored-by: Pengfei Ni <feiskyer@users.noreply.github.com>
Co-authored-by: Yicheng <36285652+zyc140345@users.noreply.github.com>
2025-04-15 13:43:38 +01:00
renovate[bot]
0553b984b7 chore(deps): update codecov/codecov-action digest to ad3126e (#1456)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 12:45:00 +01:00
github-actions[bot]
96d86d3eb0 chore(main): release 0.4.11 (#1451)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-15 11:24:06 +01:00
gossion
df17e3e728 fix: config ai provider in query (#1457)
Signed-off-by: Guoxun Wei <guwe@microsoft.com>
2025-04-15 11:11:40 +01:00
Alex Jones
80904e3063 feat: improved test coverage (#1455)
Signed-off-by: Alex Jones <alexsimonjones@gmail.com>
2025-04-14 14:15:26 +01:00
renovate[bot]
cf6f9289e1 fix(deps): update module gopkg.in/yaml.v2 to v3 (#1453)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 13:50:15 +01:00
Yicheng
a79224e2bf feat: add verbose flag to enable detailed output (#1420)
* feat: add verbose flag to enable detailed output

Signed-off-by: Yicheng <36285652+zyc140345@users.noreply.github.com>

* test: add verbose output tests for analysis.go and root.go

Signed-off-by: Yicheng <36285652+zyc140345@users.noreply.github.com>

---------

Signed-off-by: Yicheng <36285652+zyc140345@users.noreply.github.com>
2025-04-14 13:06:24 +01:00
Pengfei Ni
9ce33469d8 docs: fix the slack invite link (#1450)
Signed-off-by: Pengfei Ni <feiskyer@gmail.com>
Co-authored-by: Alex Jones <alexsimonjones@gmail.com>
2025-04-14 11:48:35 +01:00
renovate[bot]
969fe99b33 fix(deps): update module gopkg.in/yaml.v2 to v3 (#1447)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-11 09:53:59 +01:00
ju187
91d423b147 feat: call bedrock with inference profile (#1449)
* call bedrock with inference profile

Signed-off-by: Tony Chen <tony_chen@discovery.com>

* add validation and test

Signed-off-by: Tony Chen <tony_chen@discovery.com>

* update test

Signed-off-by: Tony Chen <tony_chen@discovery.com>

---------

Signed-off-by: Tony Chen <tony_chen@discovery.com>
2025-04-11 07:11:38 +01:00
49 changed files with 4445 additions and 641 deletions

View File

@@ -96,7 +96,7 @@ jobs:
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
- name: Build and push multi-arch image
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
with:
context: .
file: ./container/Dockerfile

View File

@@ -12,7 +12,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: golangci-lint
uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7
uses: golangci/golangci-lint-action@9fae48acfc02a90574d7c304a1758ef9895495fa # v7
with:
version: v2.0
only-new-issues: true

View File

@@ -73,6 +73,7 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.K8SGPT_BOT_SECRET }}
SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
# - name: Update new version in krew-index
# uses: rajatjindal/krew-release-bot@3d9faef30a82761d610544f62afddca00993eef9 # v0.0.47
@@ -106,7 +107,7 @@ jobs:
password: ${{ secrets.K8SGPT_BOT_SECRET }}
- name: Build Docker Image
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
with:
context: .
file: ./container/Dockerfile
@@ -127,7 +128,7 @@ jobs:
output-file: ./sbom-${{ env.IMAGE_NAME }}.spdx.json
- name: Attach SBOM to release
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with:
tag_name: ${{ needs.release-please.outputs.tag_name }}
files: ./sbom-${{ env.IMAGE_NAME }}.spdx.json

View File

@@ -25,6 +25,6 @@ jobs:
- name: Run test
run: go test ./... -coverprofile=coverage.txt
- name: Upload coverage to Codecov
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5
uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ k8sgpt*
dist/
bin/
pkg/server/example/example

View File

@@ -70,8 +70,28 @@ checksum:
snapshot:
name_template: "{{ incpatch .Version }}-next"
# skip: true
# The lines beneath this are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
announce:
slack:
# Whether its enabled or not.
#
# Templates: allowed (since v2.6).
enabled: true
# Message template to use while publishing.
#
# Default: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'.
# Templates: allowed.
message_template: "{{ .ProjectName }} release {{.Tag}} is out!"
# The name of the channel that the user selected as a destination for webhook messages.
channel: "#general"
# Set your Webhook's user name.
username: "K8sGPT"
# Emoji to use as the icon for this message. Overrides icon_url.
icon_emoji: ""
# URL to an image to use as the icon for this message.
icon_url: ""

View File

@@ -1 +1 @@
{".":"0.4.10"}
{".":"0.4.15"}

View File

@@ -1,5 +1,75 @@
# Changelog
## [0.4.15](https://github.com/k8sgpt-ai/k8sgpt/compare/v0.4.14...v0.4.15) (2025-04-29)
### Features
* added token for goreleaser ([#1476](https://github.com/k8sgpt-ai/k8sgpt/issues/1476)) ([85935a4](https://github.com/k8sgpt-ai/k8sgpt/commit/85935a46d8f137b0339435cf19ce7f83ead97f8c))
## [0.4.14](https://github.com/k8sgpt-ai/k8sgpt/compare/v0.4.13...v0.4.14) (2025-04-29)
### Features
* add MCP support ([#1471](https://github.com/k8sgpt-ai/k8sgpt/issues/1471)) ([e41ffd8](https://github.com/k8sgpt-ai/k8sgpt/commit/e41ffd80d01ce7ae1fac9ce7e07344020d8bf914))
* using modelName will calling completion ([#1469](https://github.com/k8sgpt-ai/k8sgpt/issues/1469)) ([f603948](https://github.com/k8sgpt-ai/k8sgpt/commit/f603948935f1c4cb171378634714577205de7b08))
## [0.4.13](https://github.com/k8sgpt-ai/k8sgpt/compare/v0.4.12...v0.4.13) (2025-04-22)
### Features
* slack announce ([#1466](https://github.com/k8sgpt-ai/k8sgpt/issues/1466)) ([3b6ad06](https://github.com/k8sgpt-ai/k8sgpt/commit/3b6ad06de1121c870fb486e0fe2bd1f87be16627))
### Bug Fixes
* reverse hpa ScalingLimited error condition ([#1366](https://github.com/k8sgpt-ai/k8sgpt/issues/1366)) ([ebb0373](https://github.com/k8sgpt-ai/k8sgpt/commit/ebb0373f69ad64a6cc43d0695d07e1d076c6366e))
### Other
* **deps:** update softprops/action-gh-release digest to da05d55 ([#1464](https://github.com/k8sgpt-ai/k8sgpt/issues/1464)) ([4434699](https://github.com/k8sgpt-ai/k8sgpt/commit/443469960a6b6791e358ee0a97e4c1dc5c3018e6))
## [0.4.12](https://github.com/k8sgpt-ai/k8sgpt/compare/v0.4.11...v0.4.12) (2025-04-17)
### Features
* new analyzers ([#1459](https://github.com/k8sgpt-ai/k8sgpt/issues/1459)) ([a128906](https://github.com/k8sgpt-ai/k8sgpt/commit/a128906136431189812d4d2dea68ea98cbfe5eeb))
### Bug Fixes
* **deps:** update module golang.org/x/net to v0.38.0 [security] ([#1462](https://github.com/k8sgpt-ai/k8sgpt/issues/1462)) ([e588fc3](https://github.com/k8sgpt-ai/k8sgpt/commit/e588fc316d29a29a7dde6abe2302833b38f1d302))
### Other
* **deps:** update codecov/codecov-action digest to ad3126e ([#1456](https://github.com/k8sgpt-ai/k8sgpt/issues/1456)) ([0553b98](https://github.com/k8sgpt-ai/k8sgpt/commit/0553b984b7c87b345f171bf6e5d632d890db689c))
## [0.4.11](https://github.com/k8sgpt-ai/k8sgpt/compare/v0.4.10...v0.4.11) (2025-04-15)
### Features
* add verbose flag to enable detailed output ([#1420](https://github.com/k8sgpt-ai/k8sgpt/issues/1420)) ([a79224e](https://github.com/k8sgpt-ai/k8sgpt/commit/a79224e2bf96f458dbc96404c8f4847970e8d2ef))
* call bedrock with inference profile ([#1449](https://github.com/k8sgpt-ai/k8sgpt/issues/1449)) ([91d423b](https://github.com/k8sgpt-ai/k8sgpt/commit/91d423b147ca18cda7d54ff19349938a894ecb85))
* improved test coverage ([#1455](https://github.com/k8sgpt-ai/k8sgpt/issues/1455)) ([80904e3](https://github.com/k8sgpt-ai/k8sgpt/commit/80904e3063b00b0536171b7b62b938938b20825a))
### Bug Fixes
* config ai provider in query ([#1457](https://github.com/k8sgpt-ai/k8sgpt/issues/1457)) ([df17e3e](https://github.com/k8sgpt-ai/k8sgpt/commit/df17e3e728591e974703527dff86de882af17790))
* **deps:** update module gopkg.in/yaml.v2 to v3 ([#1447](https://github.com/k8sgpt-ai/k8sgpt/issues/1447)) ([969fe99](https://github.com/k8sgpt-ai/k8sgpt/commit/969fe99b3320c313f1c97133cdffb668a00d5fb5))
* **deps:** update module gopkg.in/yaml.v2 to v3 ([#1453](https://github.com/k8sgpt-ai/k8sgpt/issues/1453)) ([cf6f928](https://github.com/k8sgpt-ai/k8sgpt/commit/cf6f9289e13ee729c24968fd771c901f412e8db7))
### Docs
* fix the slack invite link ([#1450](https://github.com/k8sgpt-ai/k8sgpt/issues/1450)) ([9ce3346](https://github.com/k8sgpt-ai/k8sgpt/commit/9ce33469d85aa0829e995e4b404ae85734124fb4))
## [0.4.10](https://github.com/k8sgpt-ai/k8sgpt/compare/v0.4.9...v0.4.10) (2025-04-10)

105
README.md
View File

@@ -62,7 +62,7 @@ brew install k8sgpt
<!---x-release-please-start-version-->
```
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.10/k8sgpt_386.rpm
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.15/k8sgpt_386.rpm
```
<!---x-release-please-end-->
@@ -70,7 +70,7 @@ brew install k8sgpt
<!---x-release-please-start-version-->
```
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.10/k8sgpt_amd64.rpm
sudo rpm -ivh https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.15/k8sgpt_amd64.rpm
```
<!---x-release-please-end-->
</details>
@@ -83,7 +83,7 @@ brew install k8sgpt
<!---x-release-please-start-version-->
```
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.10/k8sgpt_386.deb
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.15/k8sgpt_386.deb
sudo dpkg -i k8sgpt_386.deb
```
@@ -94,7 +94,7 @@ sudo dpkg -i k8sgpt_386.deb
<!---x-release-please-start-version-->
```
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.10/k8sgpt_amd64.deb
curl -LO https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.15/k8sgpt_amd64.deb
sudo dpkg -i k8sgpt_amd64.deb
```
@@ -109,7 +109,7 @@ sudo dpkg -i k8sgpt_amd64.deb
<!---x-release-please-start-version-->
```
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.10/k8sgpt_386.apk
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.15/k8sgpt_386.apk
apk add --allow-untrusted k8sgpt_386.apk
```
<!---x-release-please-end-->
@@ -118,7 +118,7 @@ sudo dpkg -i k8sgpt_amd64.deb
<!---x-release-please-start-version-->
```
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.10/k8sgpt_amd64.apk
wget https://github.com/k8sgpt-ai/k8sgpt/releases/download/v0.4.15/k8sgpt_amd64.apk
apk add --allow-untrusted k8sgpt_amd64.apk
```
<!---x-release-please-end-->
@@ -165,6 +165,76 @@ _This mode of operation is ideal for continuous monitoring of your cluster and c
- And use `k8sgpt analyze --explain` to get a more detailed explanation of the issues.
- You also run `k8sgpt analyze --with-doc` (with or without the explain flag) to get the official documentation from Kubernetes.
# Using with Claude Desktop
K8sGPT can be integrated with Claude Desktop to provide AI-powered Kubernetes cluster analysis. This integration requires K8sGPT v0.4.14 or later.
## Prerequisites
1. Install K8sGPT v0.4.14 or later:
```sh
brew install k8sgpt
```
2. Install Claude Desktop from the official website
3. Configure K8sGPT with your preferred AI backend:
```sh
k8sgpt auth
```
## Setup
1. Start the K8sGPT MCP server:
```sh
k8sgpt serve --mcp
```
2. In Claude Desktop:
- Open Settings
- Navigate to the Integrations section
- Add K8sGPT as a new integration
- The MCP server will be automatically detected
3. Configure Claude Desktop with the following JSON:
```json
{
"mcpServers": {
"k8sgpt": {
"command": "k8sgpt",
"args": [
"serve",
"--mcp"
]
}
}
}
```
## Usage
Once connected, you can use Claude Desktop to:
- Analyze your Kubernetes cluster
- Get detailed insights about cluster health
- Receive recommendations for fixing issues
- Query cluster information
Example commands in Claude Desktop:
- "Analyze my Kubernetes cluster"
- "What's the health status of my cluster?"
- "Show me any issues in the default namespace"
## Troubleshooting
If you encounter connection issues:
1. Ensure K8sGPT is running with the MCP server enabled
2. Verify your Kubernetes cluster is accessible
3. Check that your AI backend is properly configured
4. Restart both K8sGPT and Claude Desktop
For more information, visit our [documentation](https://docs.k8sgpt.ai).
## Analyzers
K8sGPT uses analyzers to triage and diagnose issues in your cluster. It has a set of analyzers that are built in, but
@@ -196,6 +266,9 @@ you will be able to write your own analyzers.
- [x] gateway
- [x] httproute
- [x] logAnalyzer
- [x] storageAnalyzer
- [x] securityAnalyzer
- [x] configMapAnalyzer
## Examples
@@ -393,6 +466,22 @@ k8sgpt auth default -p azureopenai
Default provider set to azureopenai
```
_Using Amazon Bedrock with inference profiles_
_System Inference Profile_
```
k8sgpt auth add --backend amazonbedrock --providerRegion us-east-1 --model arn:aws:bedrock:us-east-1:123456789012:inference-profile/my-inference-profile
```
_Application Inference Profile_
```
k8sgpt auth add --backend amazonbedrock --providerRegion us-east-1 --model arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/2uzp4s0w39t6
```
## Key Features
<details>
@@ -592,7 +681,7 @@ Please read our [contributing guide](./CONTRIBUTING.md).
## Community
Find us on [Slack](https://join.slack.com/t/k8sgpt/shared_invite/zt-276pa9uyq-pxAUr4TCVHubFxEvLZuT1Q)
Find us on [Slack](https://join.slack.com/t/k8sgpt/shared_invite/zt-332vhyaxv-bfjJwHZLXWVCB3QaXafEYQ)
<a href="https://github.com/k8sgpt-ai/k8sgpt/graphs/contributors">
<img src="https://contrib.rocks/image?repo=k8sgpt-ai/k8sgpt" />
@@ -600,4 +689,4 @@ Find us on [Slack](https://join.slack.com/t/k8sgpt/shared_invite/zt-276pa9uyq-px
## License
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fk8sgpt-ai%2Fk8sgpt.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fk8sgpt-ai%2Fk8sgpt?ref=badge_large)
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fk8sgpt-ai%2Fk8sgpt.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fk8sgpt-ai%2Fk8sgpt?ref=badge_large)

View File

@@ -23,6 +23,7 @@ import (
"github.com/k8sgpt-ai/k8sgpt/pkg/ai/interactive"
"github.com/k8sgpt-ai/k8sgpt/pkg/analysis"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
@@ -67,25 +68,45 @@ var AnalyzeCmd = &cobra.Command{
withStats,
)
verbose := viper.GetBool("verbose")
if verbose {
fmt.Println("Debug: Checking analysis configuration.")
}
if err != nil {
color.Red("Error: %v", err)
os.Exit(1)
}
if verbose {
fmt.Println("Debug: Analysis initialized.")
}
defer config.Close()
if customAnalysis {
config.RunCustomAnalysis()
if verbose {
fmt.Println("Debug: All custom analyzers completed.")
}
}
config.RunAnalysis()
if verbose {
fmt.Println("Debug: All core analyzers completed.")
}
if explain {
if err := config.GetAIResults(output, anonymize); err != nil {
err := config.GetAIResults(output, anonymize)
if verbose {
fmt.Println("Debug: Checking AI results.")
}
if err != nil {
color.Red("Error: %v", err)
os.Exit(1)
}
}
// print results
output_data, err := config.PrintOutput(output)
if verbose {
fmt.Println("Debug: Checking output.")
}
if err != nil {
color.Red("Error: %v", err)
os.Exit(1)

View File

@@ -37,6 +37,7 @@ var (
cfgFile string
kubecontext string
kubeconfig string
verbose bool
Version string
Commit string
Date string
@@ -84,6 +85,7 @@ func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", fmt.Sprintf("Default config file (%s/k8sgpt/k8sgpt.yaml)", xdg.ConfigHome))
rootCmd.PersistentFlags().StringVar(&kubecontext, "kubecontext", "", "Kubernetes context to use. Only required if out-of-cluster.")
rootCmd.PersistentFlags().StringVar(&kubeconfig, "kubeconfig", "", "Path to a kubeconfig. Only required if out-of-cluster.")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed tool actions (e.g., API calls, checks).")
}
// initConfig reads in config file and ENV variables if set.
@@ -104,6 +106,7 @@ func initConfig() {
viper.Set("kubecontext", kubecontext)
viper.Set("kubeconfig", kubeconfig)
viper.Set("verbose", verbose)
viper.SetEnvPrefix("K8SGPT")
viper.AutomaticEnv() // read in environment variables that match

30
cmd/root_test.go Normal file
View File

@@ -0,0 +1,30 @@
/*
Copyright 2023 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cmd
import (
"testing"
"github.com/spf13/viper"
)
// Test that verbose flag is correctly set in viper.
func TestInitConfig_VerboseFlag(t *testing.T) {
verbose = true
viper.Reset()
initConfig()
if !viper.GetBool("verbose") {
t.Error("Expected verbose flag to be true")
}
}

View File

@@ -38,6 +38,9 @@ var (
metricsPort string
backend string
enableHttp bool
enableMCP bool
mcpPort string
mcpHTTP bool
)
var ServeCmd = &cobra.Command{
@@ -183,6 +186,21 @@ var ServeCmd = &cobra.Command{
}
}()
if enableMCP {
// Create and start MCP server
mcpServer, err := k8sgptserver.NewMCPServer(mcpPort, aiProvider, mcpHTTP, logger)
if err != nil {
color.Red("Error creating MCP server: %v", err)
os.Exit(1)
}
go func() {
if err := mcpServer.Start(); err != nil {
color.Red("Error starting MCP server: %v", err)
os.Exit(1)
}
}()
}
server := k8sgptserver.Config{
Backend: aiProvider.Name,
Port: port,
@@ -216,4 +234,7 @@ func init() {
ServeCmd.Flags().StringVarP(&metricsPort, "metrics-port", "", "8081", "Port to run the metrics-server on")
ServeCmd.Flags().StringVarP(&backend, "backend", "b", "openai", "Backend AI provider")
ServeCmd.Flags().BoolVarP(&enableHttp, "http", "", false, "Enable REST/http using gppc-gateway")
ServeCmd.Flags().BoolVarP(&enableMCP, "mcp", "", false, "Enable Mission Control Protocol server")
ServeCmd.Flags().StringVarP(&mcpPort, "mcp-port", "", "8089", "Port to run the MCP server on")
ServeCmd.Flags().BoolVarP(&mcpHTTP, "mcp-http", "", false, "Enable HTTP mode for MCP server")
}

39
go.mod
View File

@@ -35,13 +35,19 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.1
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.5.0
github.com/IBM/watsonx-go v1.0.1
github.com/aws/aws-sdk-go v1.55.6
github.com/agiledragon/gomonkey/v2 v2.13.0
github.com/aws/aws-sdk-go v1.55.7
github.com/aws/aws-sdk-go-v2 v1.36.3
github.com/aws/aws-sdk-go-v2/config v1.29.14
github.com/aws/aws-sdk-go-v2/service/bedrock v1.33.0
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.30.0
github.com/cohere-ai/cohere-go/v2 v2.12.2
github.com/go-logr/zapr v1.3.0
github.com/google/generative-ai-go v0.19.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1
github.com/hupe1980/go-huggingface v0.0.15
github.com/kyverno/policy-reporter-kyverno-plugin v1.6.4
github.com/metoro-io/mcp-golang v0.11.0
github.com/olekukonko/tablewriter v0.0.5
github.com/oracle/oci-go-sdk/v65 v65.79.0
github.com/prometheus/prometheus v0.302.1
@@ -75,9 +81,21 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect
github.com/Microsoft/hcsshim v0.12.4 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
github.com/aws/aws-sdk-go-v2 v1.32.3 // indirect
github.com/aws/smithy-go v1.22.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect
github.com/containerd/console v1.0.4 // indirect
@@ -91,11 +109,11 @@ require (
github.com/envoyproxy/go-control-plane v0.13.1 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
github.com/expr-lang/expr v1.16.9 // indirect
github.com/expr-lang/expr v1.17.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect
github.com/google/s2a-go v0.1.9 // indirect
@@ -104,6 +122,7 @@ require (
github.com/gookit/color v1.5.4 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/invopop/jsonschema v0.12.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
@@ -120,6 +139,12 @@ require (
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sony/gobreaker v0.5.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opencensus.io v0.24.0 // indirect
@@ -243,7 +268,7 @@ require (
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect
golang.org/x/net v0.37.0
golang.org/x/net v0.38.0
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
@@ -259,7 +284,7 @@ require (
k8s.io/component-base v0.32.2 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e
k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979
oras.land/oras-go v1.2.5 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/kustomize/api v0.18.0 // indirect

82
go.sum
View File

@@ -713,6 +713,8 @@ github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/O
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
@@ -733,12 +735,42 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.32.3 h1:T0dRlFBKcdaUPGNtkBSwHZxrtis8CQU17UpNBZYd0wk=
github.com/aws/aws-sdk-go-v2 v1.32.3/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo=
github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM=
github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM=
github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g=
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/service/bedrock v1.33.0 h1:2P70khV5KDzoRs8UuplU3rAzzyLaj5kzND33Jutwpbg=
github.com/aws/aws-sdk-go-v2/service/bedrock v1.33.0/go.mod h1:rZOgAxQVRg9v5ZEQHrrKw0Gkb9DBAASeeRiwUmmXcG0=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.30.0 h1:eMOwQ8ZZK+76+08RfxeaGUtRFN6wxmD1rvqovc2kq2w=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.30.0/go.mod h1:0b5Rq7rUvSQFYHI1UO0zFTV/S6j6DUyuykXA80C+YOI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -749,6 +781,7 @@ github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd h1:rFt+Y/IK1aEZkEHchZRSq9OQbsSzIT/OrI8YFFmRIng=
github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
@@ -868,8 +901,8 @@ github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0
github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI=
github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/expr-lang/expr v1.17.2 h1:o0A99O/Px+/DTjEnQiodAgOIK9PPxL8DtXhBRKC+Iso=
github.com/expr-lang/expr v1.17.2/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -936,8 +969,8 @@ github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeH
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
@@ -1075,6 +1108,7 @@ github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/Q
github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw=
github.com/gophercloud/gophercloud/v2 v2.4.0 h1:XhP5tVEH3ni66NSNK1+0iSO6kaGPH/6srtx6Cr+8eCg=
github.com/gophercloud/gophercloud/v2 v2.4.0/go.mod h1:uJWNpTgJPSl2gyzJqcU/pIAhFUWvIkp8eE8M15n9rs4=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -1134,6 +1168,8 @@ github.com/imdario/mergo v1.0.1 h1:lFIgOs30GMaV/2+qQ+eEBLbUL6h1YosdohE3ODy4hTs=
github.com/imdario/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/ionos-cloud/sdk-go/v6 v6.3.2 h1:2mUmrZZz6cPyT9IRX0T8fBLc/7XU/eTxP2Y5tS7/09k=
github.com/ionos-cloud/sdk-go/v6 v6.3.2/go.mod h1:SXrO9OGyWjd2rZhAhEpdYN6VUAODzzqRdqA9BCviQtI=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
@@ -1152,6 +1188,7 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
@@ -1221,6 +1258,8 @@ github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/metoro-io/mcp-golang v0.11.0 h1:1k+VSE9QaeMTLn0gJ3FgE/DcjsCBsLFnz5eSFbgXUiI=
github.com/metoro-io/mcp-golang v0.11.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
@@ -1382,6 +1421,8 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg=
github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
@@ -1422,8 +1463,20 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs=
github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
@@ -1622,8 +1675,8 @@ golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1802,6 +1855,7 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@@ -2202,8 +2256,8 @@ k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJ
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4=
k8s.io/kubectl v0.32.2 h1:TAkag6+XfSBgkqK9I7ZvwtF0WVtUAvK8ZqTt+5zi1Us=
k8s.io/kubectl v0.32.2/go.mod h1:+h/NQFSPxiDZYX/WZaWw9fwYezGLISP0ud8nQKg+3g8=
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro=
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 h1:jgJW5IePPXLGB8e/1wvd0Ich9QE97RvvF3a8J3fP/Lg=
k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
knative.dev/pkg v0.0.0-20241026180704-25f6002b00f3 h1:uUSDGlOIkdPT4svjlhi+JEnP2Ufw7AM/F5QDYiEL02U=
knative.dev/pkg v0.0.0-20241026180704-25f6002b00f3/go.mod h1:FeMbTLlxQqSASwlRCrYEOsZ0OKUgSj52qxhECwYCJsw=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=

View File

@@ -5,29 +5,31 @@ import (
"errors"
"fmt"
"os"
"regexp"
"strings"
"github.com/aws/aws-sdk-go/service/bedrockruntime/bedrockruntimeiface"
"github.com/k8sgpt-ai/k8sgpt/pkg/ai/bedrock_support"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/bedrockruntime"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/bedrock"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
)
const amazonbedrockAIClientName = "amazonbedrock"
// AmazonBedRockClient represents the client for interacting with the AmazonCompletion Bedrock service.
// AmazonBedRockClient represents the client for interacting with the Amazon Bedrock service.
type AmazonBedRockClient struct {
nopCloser
client bedrockruntimeiface.BedrockRuntimeAPI
client BedrockRuntimeAPI
mgmtClient BedrockManagementAPI
model *bedrock_support.BedrockModel
temperature float32
topP float32
maxTokens int
models []bedrock_support.BedrockModel
configName string // Added to support multiple configurations
}
// AmazonCompletion BedRock support region list US East (N. Virginia),US West (Oregon),Asia Pacific (Singapore),Asia Pacific (Tokyo),Europe (Frankfurt)
@@ -41,6 +43,8 @@ const (
AP_Northeast_1 = "ap-northeast-1"
EU_Central_1 = "eu-central-1"
AP_South_1 = "ap-south-1"
US_Gov_West_1 = "us-gov-west-1"
US_Gov_East_1 = "us-gov-east-1"
)
var BEDROCKER_SUPPORTED_REGION = []string{
@@ -50,13 +54,39 @@ var BEDROCKER_SUPPORTED_REGION = []string{
AP_Northeast_1,
EU_Central_1,
AP_South_1,
US_Gov_West_1,
US_Gov_East_1,
}
var defaultModels = []bedrock_support.BedrockModel{
{
Name: "anthropic.claude-3-5-sonnet-20240620-v1:0",
Name: "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
Completion: &bedrock_support.CohereMessagesCompletion{},
Response: &bedrock_support.CohereMessagesResponse{},
Config: bedrock_support.BedrockModelConfig{
// sensible defaults
MaxTokens: 100,
Temperature: 0.5,
TopP: 0.9,
ModelName: "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
},
},
{
Name: "eu.anthropic.claude-3-7-sonnet-20250219-v1:0",
Completion: &bedrock_support.CohereMessagesCompletion{},
Response: &bedrock_support.CohereMessagesResponse{},
Config: bedrock_support.BedrockModelConfig{
// sensible defaults
MaxTokens: 100,
Temperature: 0.5,
TopP: 0.9,
ModelName: "eu.anthropic.claude-3-7-sonnet-20250219-v1:0",
},
},
{
Name: "anthropic.claude-3-5-sonnet-20240620-v1:0",
Completion: &bedrock_support.CohereCompletion{},
Response: &bedrock_support.CohereResponse{},
Config: bedrock_support.BedrockModelConfig{
// sensible defaults
MaxTokens: 100,
@@ -249,7 +279,6 @@ func NewAmazonBedRockClient(models []bedrock_support.BedrockModel) *AmazonBedRoc
// GetModelOrDefault check config region
func GetRegionOrDefault(region string) string {
if os.Getenv("AWS_DEFAULT_REGION") != "" {
region = os.Getenv("AWS_DEFAULT_REGION")
}
@@ -264,6 +293,17 @@ func GetRegionOrDefault(region string) string {
return BEDROCK_DEFAULT_REGION
}
func validateModelArn(model string) bool {
var re = regexp.MustCompile(`(?m)^arn:(?P<Partition>[^:\n]*):bedrock:(?P<Region>[^:\n]*):(?P<AccountID>[^:\n]*):(?P<Ignore>(?P<ResourceType>[^:\/\n]*)[:\/])?(?P<Resource>.*)$`)
return re.MatchString(model)
}
func validateInferenceProfileArn(inferenceProfile string) bool {
// Support both inference-profile and application-inference-profile formats
var re = regexp.MustCompile(`(?m)^arn:(?P<Partition>[^:\n]*):bedrock:(?P<Region>[^:\n]*):(?P<AccountID>[^:\n]*):(?:inference-profile|application-inference-profile)\/(?P<ProfileName>.+)$`)
return re.MatchString(inferenceProfile)
}
// Get model from string
func (a *AmazonBedRockClient) getModelFromString(model string) (*bedrock_support.BedrockModel, error) {
if model == "" {
@@ -293,6 +333,11 @@ func (a *AmazonBedRockClient) getModelFromString(model string) (*bedrock_support
strings.Contains(modelConfigNameLower, modelLower) || strings.Contains(modelLower, modelConfigNameLower) {
// Create a copy to avoid returning a pointer to a loop variable
modelCopy := a.models[i]
// for partial match, set the model name to the input string if it is a valid ARN
if validateModelArn(modelLower) {
modelCopy.Config.ModelName = modelLower
}
return &modelCopy, nil
}
}
@@ -307,36 +352,141 @@ func (a *AmazonBedRockClient) Configure(config IAIConfig) error {
a.models = defaultModels
}
// Create a new AWS session
providerRegion := GetRegionOrDefault(config.GetProviderRegion())
// Get the model input
modelInput := config.GetModel()
sess, err := session.NewSession(&aws.Config{
Region: aws.String(providerRegion),
})
// Determine the appropriate region to use
var region string
if err != nil {
return err
// Check if the model input is actually an inference profile ARN
if validateInferenceProfileArn(modelInput) {
// Extract the region from the inference profile ARN
arnParts := strings.Split(modelInput, ":")
if len(arnParts) >= 4 {
region = arnParts[3]
} else {
return fmt.Errorf("could not extract region from inference profile ARN: %s", modelInput)
}
} else {
// Use the provided region or default
region = GetRegionOrDefault(config.GetProviderRegion())
}
foundModel, err := a.getModelFromString(config.GetModel())
if err != nil {
return err
// Only create AWS clients if they haven't been injected (for testing)
if a.client == nil || a.mgmtClient == nil {
// Create a new AWS config with the determined region
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(region),
)
if err != nil {
return fmt.Errorf("failed to load AWS config for region %s: %w", region, err)
}
// Create clients with the config
a.client = bedrockruntime.NewFromConfig(cfg)
a.mgmtClient = bedrock.NewFromConfig(cfg)
}
// Create a new BedrockRuntime client
a.client = bedrockruntime.New(sess)
a.model = foundModel
a.model.Config.ModelName = foundModel.Name
// Handle model selection based on input type
if validateInferenceProfileArn(modelInput) {
// Get the inference profile details
profile, err := a.getInferenceProfile(context.Background(), modelInput)
if err != nil {
// Instead of using a fallback model, throw an error
return fmt.Errorf("failed to get inference profile: %v", err)
} else {
// Extract the model ID from the inference profile
modelID, err := a.extractModelFromInferenceProfile(profile)
if err != nil {
return fmt.Errorf("failed to extract model ID from inference profile: %v", err)
}
// Find the model configuration for the extracted model ID
foundModel, err := a.getModelFromString(modelID)
if err != nil {
// Instead of using a fallback model, throw an error
return fmt.Errorf("failed to find model configuration for %s: %v", modelID, err)
}
a.model = foundModel
// Use the inference profile ARN as the model ID for API calls
a.model.Config.ModelName = modelInput
}
} else {
// Regular model ID provided
foundModel, err := a.getModelFromString(modelInput)
if err != nil {
return err
}
a.model = foundModel
a.model.Config.ModelName = foundModel.Config.ModelName
}
// Set common configuration parameters
a.temperature = config.GetTemperature()
a.topP = config.GetTopP()
a.maxTokens = config.GetMaxTokens()
a.configName = config.GetConfigName() // Store the config name
return nil
}
// getInferenceProfile retrieves the inference profile details from Amazon Bedrock
func (a *AmazonBedRockClient) getInferenceProfile(ctx context.Context, inferenceProfileARN string) (*bedrock.GetInferenceProfileOutput, error) {
// Extract the profile ID from the ARN
// ARN format: arn:aws:bedrock:region:account-id:inference-profile/profile-id
// or arn:aws:bedrock:region:account-id:application-inference-profile/profile-id
parts := strings.Split(inferenceProfileARN, "/")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid inference profile ARN format: %s", inferenceProfileARN)
}
profileID := parts[1]
// Create the input for the GetInferenceProfile API call
input := &bedrock.GetInferenceProfileInput{
InferenceProfileIdentifier: aws.String(profileID),
}
// Call the GetInferenceProfile API
output, err := a.mgmtClient.GetInferenceProfile(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to get inference profile: %w", err)
}
return output, nil
}
// extractModelFromInferenceProfile extracts the model ID from the inference profile
func (a *AmazonBedRockClient) extractModelFromInferenceProfile(profile *bedrock.GetInferenceProfileOutput) (string, error) {
if profile == nil || len(profile.Models) == 0 {
return "", fmt.Errorf("inference profile does not contain any models")
}
// Check if the first model has a non-nil ModelArn
if profile.Models[0].ModelArn == nil {
return "", fmt.Errorf("model information is missing in inference profile")
}
// Get the first model ARN from the profile
modelARN := aws.ToString(profile.Models[0].ModelArn)
if modelARN == "" {
return "", fmt.Errorf("model ARN is empty in inference profile")
}
// Extract the model ID from the ARN
// ARN format: arn:aws:bedrock:region::foundation-model/model-id
parts := strings.Split(modelARN, "/")
if len(parts) != 2 {
return "", fmt.Errorf("invalid model ARN format: %s", modelARN)
}
modelID := parts[1]
return modelID, nil
}
// GetCompletion sends a request to the model for generating completion based on the provided prompt.
func (a *AmazonBedRockClient) GetCompletion(ctx context.Context, prompt string) (string, error) {
// override config defaults
a.model.Config.MaxTokens = a.maxTokens
a.model.Config.Temperature = a.temperature
@@ -346,23 +496,23 @@ func (a *AmazonBedRockClient) GetCompletion(ctx context.Context, prompt string)
if err != nil {
return "", err
}
// Build the parameters for the model invocation
params := &bedrockruntime.InvokeModelInput{
Body: body,
ModelId: aws.String(a.model.Name),
ModelId: aws.String(a.model.Config.ModelName),
ContentType: aws.String("application/json"),
Accept: aws.String("application/json"),
}
// Invoke the model
resp, err := a.client.InvokeModelWithContext(ctx, params)
// Invoke the model
resp, err := a.client.InvokeModel(ctx, params)
if err != nil {
return "", err
}
// Parse the response
return a.model.Response.ParseResponse(resp.Body)
}
// GetName returns the name of the AmazonBedRockClient.

View File

@@ -0,0 +1,103 @@
package ai
import (
"context"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/bedrock"
"github.com/aws/aws-sdk-go-v2/service/bedrock/types"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
"github.com/k8sgpt-ai/k8sgpt/pkg/ai/bedrock_support"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Mock for Bedrock Management Client
type MockBedrockClient struct {
mock.Mock
}
func (m *MockBedrockClient) GetInferenceProfile(ctx context.Context, params *bedrock.GetInferenceProfileInput, optFns ...func(*bedrock.Options)) (*bedrock.GetInferenceProfileOutput, error) {
args := m.Called(ctx, params)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*bedrock.GetInferenceProfileOutput), args.Error(1)
}
// Mock for Bedrock Runtime Client
type MockBedrockRuntimeClient struct {
mock.Mock
}
func (m *MockBedrockRuntimeClient) InvokeModel(ctx context.Context, params *bedrockruntime.InvokeModelInput, optFns ...func(*bedrockruntime.Options)) (*bedrockruntime.InvokeModelOutput, error) {
args := m.Called(ctx, params)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*bedrockruntime.InvokeModelOutput), args.Error(1)
}
// TestBedrockInferenceProfileARNWithMocks tests the inference profile ARN validation with mocks
func TestBedrockInferenceProfileARNWithMocks(t *testing.T) {
// Create test models
testModels := []bedrock_support.BedrockModel{
{
Name: "anthropic.claude-3-5-sonnet-20240620-v1:0",
Completion: &bedrock_support.CohereMessagesCompletion{},
Response: &bedrock_support.CohereMessagesResponse{},
Config: bedrock_support.BedrockModelConfig{
MaxTokens: 100,
Temperature: 0.5,
TopP: 0.9,
ModelName: "anthropic.claude-3-5-sonnet-20240620-v1:0",
},
},
}
// Create a client with test models
client := &AmazonBedRockClient{models: testModels}
// Create mock clients
mockMgmtClient := new(MockBedrockClient)
mockRuntimeClient := new(MockBedrockRuntimeClient)
// Inject mock clients into the AmazonBedRockClient
client.mgmtClient = mockMgmtClient
client.client = mockRuntimeClient
// Test with a valid inference profile ARN
inferenceProfileARN := "arn:aws:bedrock:us-east-1:123456789012:inference-profile/my-profile"
// Setup mock response for GetInferenceProfile
mockMgmtClient.On("GetInferenceProfile", mock.Anything, &bedrock.GetInferenceProfileInput{
InferenceProfileIdentifier: aws.String("my-profile"),
}).Return(&bedrock.GetInferenceProfileOutput{
Models: []types.InferenceProfileModel{
{
ModelArn: aws.String("arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20240620-v1:0"),
},
},
}, nil)
// Configure the client with the inference profile ARN
config := AIProvider{
Model: inferenceProfileARN,
ProviderRegion: "us-east-1",
}
// Test the Configure method with the inference profile ARN
err := client.Configure(&config)
// Verify that the configuration was successful
assert.NoError(t, err)
assert.Equal(t, inferenceProfileARN, client.model.Config.ModelName)
// Verify that the mock was called
mockMgmtClient.AssertExpectations(t)
}

View File

@@ -31,6 +31,17 @@ var testModels = []bedrock_support.BedrockModel{
ModelName: "anthropic.claude-3-5-sonnet-20241022-v2:0",
},
},
{
Name: "anthropic.claude-3-7-sonnet-20250219-v1:0",
Completion: &bedrock_support.CohereCompletion{},
Response: &bedrock_support.CohereResponse{},
Config: bedrock_support.BedrockModelConfig{
MaxTokens: 100,
Temperature: 0.5,
TopP: 0.9,
ModelName: "anthropic.claude-3-7-sonnet-20250219-v1:0",
},
},
}
func TestBedrockModelConfig(t *testing.T) {
@@ -41,7 +52,79 @@ func TestBedrockModelConfig(t *testing.T) {
assert.Equal(t, foundModel.Config.MaxTokens, 100)
assert.Equal(t, foundModel.Config.Temperature, float32(0.5))
assert.Equal(t, foundModel.Config.TopP, float32(0.9))
assert.Equal(t, foundModel.Config.ModelName, "anthropic.claude-3-5-sonnet-20240620-v1:0")
assert.Equal(t, foundModel.Config.ModelName, "arn:aws:bedrock:us-east-1:*:inference-policy/anthropic.claude-3-5-sonnet-20240620-v1:0")
}
func TestBedrockInvalidModel(t *testing.T) {
client := &AmazonBedRockClient{models: testModels}
foundModel, err := client.getModelFromString("arn:aws:s3:us-east-1:*:inference-policy/anthropic.claude-3-5-sonnet-20240620-v1:0")
assert.Nil(t, err, "Error should be nil")
assert.Equal(t, foundModel.Config.MaxTokens, 100)
}
func TestBedrockInferenceProfileARN(t *testing.T) {
// Create a mock client with test models
client := &AmazonBedRockClient{models: testModels}
// Test with a valid inference profile ARN
inferenceProfileARN := "arn:aws:bedrock:us-east-1:123456789012:inference-profile/my-profile"
config := AIProvider{
Model: inferenceProfileARN,
ProviderRegion: "us-east-1",
}
// This will fail in a real environment without mocks, but we're just testing the validation logic
err := client.Configure(&config)
// We expect an error since we can't actually call AWS in tests
assert.NotNil(t, err, "Error should not be nil without AWS mocks")
// Test with a valid application inference profile ARN
appInferenceProfileARN := "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile"
config = AIProvider{
Model: appInferenceProfileARN,
ProviderRegion: "us-east-1",
}
// This will fail in a real environment without mocks, but we're just testing the validation logic
err = client.Configure(&config)
// We expect an error since we can't actually call AWS in tests
assert.NotNil(t, err, "Error should not be nil without AWS mocks")
// Test with an invalid inference profile ARN format
invalidARN := "arn:aws:bedrock:us-east-1:123456789012:invalid-resource/my-profile"
config = AIProvider{
Model: invalidARN,
ProviderRegion: "us-east-1",
}
err = client.Configure(&config)
assert.NotNil(t, err, "Error should not be nil for invalid inference profile ARN format")
}
func TestBedrockGetCompletionInferenceProfile(t *testing.T) {
modelName := "arn:aws:bedrock:us-east-1:*:inference-policy/anthropic.claude-3-5-sonnet-20240620-v1:0"
var inferenceModelModels = []bedrock_support.BedrockModel{
{
Name: "anthropic.claude-3-5-sonnet-20240620-v1:0",
Completion: &bedrock_support.CohereMessagesCompletion{},
Response: &bedrock_support.CohereMessagesResponse{},
Config: bedrock_support.BedrockModelConfig{
MaxTokens: 100,
Temperature: 0.5,
TopP: 0.9,
ModelName: modelName,
},
},
}
client := &AmazonBedRockClient{models: inferenceModelModels}
config := AIProvider{
Model: modelName,
}
err := client.Configure(&config)
assert.Nil(t, err, "Error should be nil")
assert.Equal(t, modelName, client.model.Config.ModelName, "Model name should match")
}
func TestGetModelFromString(t *testing.T) {
@@ -129,3 +212,54 @@ func TestDefaultModels(t *testing.T) {
assert.NoError(t, err, "Should find the model")
assert.Equal(t, "anthropic.claude-v2", model.Name, "Should find the correct model")
}
func TestValidateInferenceProfileArn(t *testing.T) {
tests := []struct {
name string
arn string
valid bool
}{
{
name: "valid inference profile ARN",
arn: "arn:aws:bedrock:us-east-1:123456789012:inference-profile/my-profile",
valid: true,
},
{
name: "valid application inference profile ARN",
arn: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile",
valid: true,
},
{
name: "invalid service in ARN",
arn: "arn:aws:s3:us-east-1:123456789012:inference-profile/my-profile",
valid: false,
},
{
name: "invalid resource type in ARN",
arn: "arn:aws:bedrock:us-east-1:123456789012:model/my-profile",
valid: false,
},
{
name: "malformed ARN",
arn: "arn:aws:bedrock:us-east-1:inference-profile/my-profile",
valid: false,
},
{
name: "not an ARN",
arn: "not-an-arn",
valid: false,
},
{
name: "empty string",
arn: "",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := validateInferenceProfileArn(tt.arn)
assert.Equal(t, tt.valid, result, "validateInferenceProfileArn() result should match expected")
})
}
}

View File

@@ -0,0 +1,18 @@
package ai
import (
"context"
"github.com/aws/aws-sdk-go-v2/service/bedrock"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
)
// BedrockManagementAPI defines the interface for Bedrock management operations
type BedrockManagementAPI interface {
GetInferenceProfile(ctx context.Context, params *bedrock.GetInferenceProfileInput, optFns ...func(*bedrock.Options)) (*bedrock.GetInferenceProfileOutput, error)
}
// BedrockRuntimeAPI defines the interface for Bedrock runtime operations
type BedrockRuntimeAPI interface {
InvokeModel(ctx context.Context, params *bedrockruntime.InvokeModelInput, optFns ...func(*bedrockruntime.Options)) (*bedrockruntime.InvokeModelOutput, error)
}

View File

@@ -173,6 +173,20 @@ func TestAmazonCompletion_GetCompletion_UnsupportedModel(t *testing.T) {
assert.Contains(t, err.Error(), "model unsupported-model is not supported")
}
func TestAmazonCompletion_GetCompletion_Inference_Profile(t *testing.T) {
completion := &AmazonCompletion{}
modelConfig := BedrockModelConfig{
MaxTokens: 200,
Temperature: 0.5,
TopP: 0.7,
ModelName: "arn:aws:bedrock:us-east-1:*:inference-policy/anthropic.claude-3-5-sonnet-20240620-v1:0",
}
prompt := "Test prompt"
_, err := completion.GetCompletion(context.Background(), prompt, modelConfig)
assert.NoError(t, err)
}
func Test_isModelSupported(t *testing.T) {
assert.True(t, isModelSupported("anthropic.claude-v2"))
assert.False(t, isModelSupported("unsupported-model"))

87
pkg/ai/factory.go Normal file
View File

@@ -0,0 +1,87 @@
/*
Copyright 2023 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package ai
import (
"github.com/spf13/viper"
)
// AIClientFactory is an interface for creating AI clients
type AIClientFactory interface {
NewClient(provider string) IAI
}
// DefaultAIClientFactory is the default implementation of AIClientFactory
type DefaultAIClientFactory struct{}
// NewClient creates a new AI client using the default implementation
func (f *DefaultAIClientFactory) NewClient(provider string) IAI {
return NewClient(provider)
}
// ConfigProvider is an interface for accessing configuration
type ConfigProvider interface {
UnmarshalKey(key string, rawVal interface{}) error
}
// ViperConfigProvider is the default implementation of ConfigProvider using Viper
type ViperConfigProvider struct{}
// UnmarshalKey unmarshals a key from the configuration using Viper
func (p *ViperConfigProvider) UnmarshalKey(key string, rawVal interface{}) error {
return viper.UnmarshalKey(key, rawVal)
}
// Default instances to be used
var (
DefaultClientFactory = &DefaultAIClientFactory{}
DefaultConfigProvider = &ViperConfigProvider{}
)
// For testing - these variables can be overridden in tests
var (
testAIClientFactory AIClientFactory = nil
testConfigProvider ConfigProvider = nil
)
// GetAIClientFactory returns the test factory if set, otherwise the default
func GetAIClientFactory() AIClientFactory {
if testAIClientFactory != nil {
return testAIClientFactory
}
return DefaultClientFactory
}
// GetConfigProvider returns the test provider if set, otherwise the default
func GetConfigProvider() ConfigProvider {
if testConfigProvider != nil {
return testConfigProvider
}
return DefaultConfigProvider
}
// For testing - set the test implementations
func SetTestAIClientFactory(factory AIClientFactory) {
testAIClientFactory = factory
}
func SetTestConfigProvider(provider ConfigProvider) {
testConfigProvider = provider
}
// Reset test implementations
func ResetTestImplementations() {
testAIClientFactory = nil
testConfigProvider = nil
}

View File

@@ -71,22 +71,24 @@ type nopCloser struct{}
func (nopCloser) Close() {}
// IAIConfig represents the configuration for an AI provider
type IAIConfig interface {
GetPassword() string
GetModel() string
GetProviderRegion() string
GetTemperature() float32
GetTopP() float32
GetMaxTokens() int
GetConfigName() string
GetPassword() string
GetBaseURL() string
GetProxyEndpoint() string
GetEndpointName() string
GetEngine() string
GetTemperature() float32
GetProviderRegion() string
GetTopP() float32
GetTopK() int32
GetMaxTokens() int
GetProviderId() string
GetCompartmentId() string
GetOrganizationId() string
GetCustomHeaders() []http.Header
GetTopK() int32
}
func NewClient(provider string) IAI {
@@ -104,24 +106,95 @@ type AIConfiguration struct {
DefaultProvider string `mapstructure:"defaultprovider"`
}
// AIProvider represents a provider configuration
type AIProvider struct {
Name string `mapstructure:"name"`
Model string `mapstructure:"model"`
Password string `mapstructure:"password" yaml:"password,omitempty"`
BaseURL string `mapstructure:"baseurl" yaml:"baseurl,omitempty"`
ProxyEndpoint string `mapstructure:"proxyEndpoint" yaml:"proxyEndpoint,omitempty"`
ProxyPort string `mapstructure:"proxyPort" yaml:"proxyPort,omitempty"`
EndpointName string `mapstructure:"endpointname" yaml:"endpointname,omitempty"`
Engine string `mapstructure:"engine" yaml:"engine,omitempty"`
Temperature float32 `mapstructure:"temperature" yaml:"temperature,omitempty"`
ProviderRegion string `mapstructure:"providerregion" yaml:"providerregion,omitempty"`
ProviderId string `mapstructure:"providerid" yaml:"providerid,omitempty"`
CompartmentId string `mapstructure:"compartmentid" yaml:"compartmentid,omitempty"`
TopP float32 `mapstructure:"topp" yaml:"topp,omitempty"`
TopK int32 `mapstructure:"topk" yaml:"topk,omitempty"`
MaxTokens int `mapstructure:"maxtokens" yaml:"maxtokens,omitempty"`
OrganizationId string `mapstructure:"organizationid" yaml:"organizationid,omitempty"`
CustomHeaders []http.Header `mapstructure:"customHeaders"`
Name string `mapstructure:"name" json:"name"`
Model string `mapstructure:"model" json:"model,omitempty"`
Password string `mapstructure:"password" yaml:"password,omitempty" json:"password,omitempty"`
BaseURL string `mapstructure:"baseurl" yaml:"baseurl,omitempty" json:"baseurl,omitempty"`
ProxyEndpoint string `mapstructure:"proxyEndpoint" yaml:"proxyEndpoint,omitempty" json:"proxyEndpoint,omitempty"`
ProxyPort string `mapstructure:"proxyPort" yaml:"proxyPort,omitempty" json:"proxyPort,omitempty"`
EndpointName string `mapstructure:"endpointname" yaml:"endpointname,omitempty" json:"endpointname,omitempty"`
Engine string `mapstructure:"engine" yaml:"engine,omitempty" json:"engine,omitempty"`
Temperature float32 `mapstructure:"temperature" yaml:"temperature,omitempty" json:"temperature,omitempty"`
ProviderRegion string `mapstructure:"providerregion" yaml:"providerregion,omitempty" json:"providerregion,omitempty"`
ProviderId string `mapstructure:"providerid" yaml:"providerid,omitempty" json:"providerid,omitempty"`
CompartmentId string `mapstructure:"compartmentid" yaml:"compartmentid,omitempty" json:"compartmentid,omitempty"`
TopP float32 `mapstructure:"topp" yaml:"topp,omitempty" json:"topp,omitempty"`
TopK int32 `mapstructure:"topk" yaml:"topk,omitempty" json:"topk,omitempty"`
MaxTokens int `mapstructure:"maxtokens" yaml:"maxtokens,omitempty" json:"maxtokens,omitempty"`
OrganizationId string `mapstructure:"organizationid" yaml:"organizationid,omitempty" json:"organizationid,omitempty"`
CustomHeaders []http.Header `mapstructure:"customHeaders" json:"customHeaders,omitempty"`
Configs []AIProviderConfig `mapstructure:"configs" json:"configs,omitempty"`
DefaultConfig int `mapstructure:"defaultConfig" json:"defaultConfig,omitempty"`
}
// AIProviderConfig represents a single configuration for a provider
type AIProviderConfig struct {
Model string `mapstructure:"model" json:"model"`
ProviderRegion string `mapstructure:"providerRegion" json:"providerRegion"`
Temperature float32 `mapstructure:"temperature" json:"temperature"`
TopP float32 `mapstructure:"topP" json:"topP"`
MaxTokens int `mapstructure:"maxTokens" json:"maxTokens"`
ConfigName string `mapstructure:"configName" json:"configName"`
Password string `mapstructure:"password" yaml:"password,omitempty" json:"password,omitempty"`
BaseURL string `mapstructure:"baseurl" yaml:"baseurl,omitempty" json:"baseurl,omitempty"`
ProxyEndpoint string `mapstructure:"proxyEndpoint" yaml:"proxyEndpoint,omitempty" json:"proxyEndpoint,omitempty"`
EndpointName string `mapstructure:"endpointname" yaml:"endpointname,omitempty" json:"endpointname,omitempty"`
Engine string `mapstructure:"engine" yaml:"engine,omitempty" json:"engine,omitempty"`
ProviderId string `mapstructure:"providerid" yaml:"providerid,omitempty" json:"providerid,omitempty"`
CompartmentId string `mapstructure:"compartmentid" yaml:"compartmentid,omitempty" json:"compartmentid,omitempty"`
TopK int32 `mapstructure:"topk" yaml:"topk,omitempty" json:"topk,omitempty"`
OrganizationId string `mapstructure:"organizationid" yaml:"organizationid,omitempty" json:"organizationid,omitempty"`
CustomHeaders []http.Header `mapstructure:"customHeaders" json:"customHeaders,omitempty"`
}
// GetConfigName returns the configuration name
func (p *AIProvider) GetConfigName() string {
if len(p.Configs) > 0 && p.DefaultConfig >= 0 && p.DefaultConfig < len(p.Configs) {
return p.Configs[p.DefaultConfig].ConfigName
}
return ""
}
// GetModel returns the model name
func (p *AIProvider) GetModel() string {
if len(p.Configs) > 0 && p.DefaultConfig >= 0 && p.DefaultConfig < len(p.Configs) {
return p.Configs[p.DefaultConfig].Model
}
return p.Model
}
// GetProviderRegion returns the provider region
func (p *AIProvider) GetProviderRegion() string {
if len(p.Configs) > 0 && p.DefaultConfig >= 0 && p.DefaultConfig < len(p.Configs) {
return p.Configs[p.DefaultConfig].ProviderRegion
}
return p.ProviderRegion
}
// GetTemperature returns the temperature
func (p *AIProvider) GetTemperature() float32 {
if len(p.Configs) > 0 && p.DefaultConfig >= 0 && p.DefaultConfig < len(p.Configs) {
return p.Configs[p.DefaultConfig].Temperature
}
return p.Temperature
}
// GetTopP returns the top P value
func (p *AIProvider) GetTopP() float32 {
if len(p.Configs) > 0 && p.DefaultConfig >= 0 && p.DefaultConfig < len(p.Configs) {
return p.Configs[p.DefaultConfig].TopP
}
return p.TopP
}
// GetMaxTokens returns the maximum number of tokens
func (p *AIProvider) GetMaxTokens() int {
if len(p.Configs) > 0 && p.DefaultConfig >= 0 && p.DefaultConfig < len(p.Configs) {
return p.Configs[p.DefaultConfig].MaxTokens
}
return p.MaxTokens
}
func (p *AIProvider) GetBaseURL() string {
@@ -136,36 +209,17 @@ func (p *AIProvider) GetEndpointName() string {
return p.EndpointName
}
func (p *AIProvider) GetTopP() float32 {
return p.TopP
}
func (p *AIProvider) GetTopK() int32 {
return p.TopK
}
func (p *AIProvider) GetMaxTokens() int {
return p.MaxTokens
}
func (p *AIProvider) GetPassword() string {
return p.Password
}
func (p *AIProvider) GetModel() string {
return p.Model
}
func (p *AIProvider) GetEngine() string {
return p.Engine
}
func (p *AIProvider) GetTemperature() float32 {
return p.Temperature
}
func (p *AIProvider) GetProviderRegion() string {
return p.ProviderRegion
}
func (p *AIProvider) GetProviderId() string {
return p.ProviderId

View File

@@ -76,6 +76,10 @@ func (m *mockConfig) GetProviderRegion() string {
return ""
}
func (m *mockConfig) GetConfigName() string {
return ""
}
func TestOpenAIClient_CustomHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Value1", r.Header.Get("X-Custom-Header-1"))

View File

@@ -18,6 +18,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"reflect"
"strings"
"sync"
"time"
@@ -89,19 +90,35 @@ func NewAnalysis(
// Get kubernetes client from viper.
kubecontext := viper.GetString("kubecontext")
kubeconfig := viper.GetString("kubeconfig")
verbose := viper.GetBool("verbose")
client, err := kubernetes.NewClient(kubecontext, kubeconfig)
if verbose {
fmt.Println("Debug: Checking kubernetes client initialization.")
}
if err != nil {
return nil, fmt.Errorf("initialising kubernetes client: %w", err)
}
if verbose {
fmt.Printf("Debug: Kubernetes client initialized, server=%s.\n", client.Config.Host)
}
// Load remote cache if it is configured.
cache, err := cache.GetCacheConfiguration()
if verbose {
fmt.Println("Debug: Checking cache configuration.")
}
if err != nil {
return nil, err
}
if verbose {
fmt.Printf("Debug: Cache configuration loaded, type=%s.\n", cache.GetName())
}
if noCache {
cache.DisableCache()
if verbose {
fmt.Println("Debug: Cache disabled.")
}
}
a := &Analysis{
@@ -117,12 +134,31 @@ func NewAnalysis(
WithDoc: withDoc,
WithStats: withStats,
}
if verbose {
fmt.Print("Debug: Analysis configuration loaded, ")
fmt.Printf("filters=%v, language=%s, ", filters, language)
if namespace == "" {
fmt.Printf("namespace=none, ")
} else {
fmt.Printf("namespace=%s, ", namespace)
}
if labelSelector == "" {
fmt.Printf("labelSelector=none, ")
} else {
fmt.Printf("labelSelector=%s, ", labelSelector)
}
fmt.Printf("explain=%t, maxConcurrency=%d, ", explain, maxConcurrency)
fmt.Printf("withDoc=%t, withStats=%t.\n", withDoc, withStats)
}
if !explain {
// Return early if AI use was not requested.
return a, nil
}
var configAI ai.AIConfiguration
if verbose {
fmt.Println("Debug: Checking AI configuration.")
}
if err := viper.UnmarshalKey("ai", &configAI); err != nil {
return nil, err
}
@@ -135,10 +171,16 @@ func NewAnalysis(
// Hence, use the default provider only if the backend is not specified by the user.
if configAI.DefaultProvider != "" && backend == "" {
backend = configAI.DefaultProvider
if verbose {
fmt.Printf("Debug: Using default AI provider %s.\n", backend)
}
}
if backend == "" {
backend = "openai"
if verbose {
fmt.Printf("Debug: Using default AI provider %s.\n", backend)
}
}
var aiProvider ai.AIProvider
@@ -153,12 +195,23 @@ func NewAnalysis(
return nil, fmt.Errorf("AI provider %s not specified in configuration. Please run k8sgpt auth", backend)
}
if verbose {
fmt.Printf("Debug: AI configuration loaded, provider=%s, ", backend)
fmt.Printf("baseUrl=%s, model=%s.\n", aiProvider.BaseURL, aiProvider.Model)
}
aiClient := ai.NewClient(aiProvider.Name)
customHeaders := util.NewHeaders(httpHeaders)
aiProvider.CustomHeaders = customHeaders
if verbose {
fmt.Println("Debug: Checking AI client initialization.")
}
if err := aiClient.Configure(&aiProvider); err != nil {
return nil, err
}
if verbose {
fmt.Println("Debug: AI client initialized.")
}
a.AIClient = aiClient
a.AnalysisAIProvider = aiProvider.Name
return a, nil
@@ -182,6 +235,18 @@ func (a *Analysis) RunCustomAnalysis() {
semaphore := make(chan struct{}, a.MaxConcurrency)
var wg sync.WaitGroup
var mutex sync.Mutex
verbose := viper.GetBool("verbose")
if verbose {
if len(customAnalyzers) == 0 {
fmt.Println("Debug: No custom analyzers found.")
} else {
cAnalyzerNames := make([]string, len(customAnalyzers))
for i, cAnalyzer := range customAnalyzers {
cAnalyzerNames[i] = cAnalyzer.Name
}
fmt.Printf("Debug: Found custom analyzers %v.\n", cAnalyzerNames)
}
}
for _, cAnalyzer := range customAnalyzers {
wg.Add(1)
semaphore <- struct{}{}
@@ -194,6 +259,9 @@ func (a *Analysis) RunCustomAnalysis() {
mutex.Unlock()
return
}
if verbose {
fmt.Printf("Debug: %s launched.\n", cAnalyzer.Name)
}
result, err := canClient.Run()
if result.Kind == "" {
@@ -206,10 +274,16 @@ func (a *Analysis) RunCustomAnalysis() {
mutex.Lock()
a.Errors = append(a.Errors, fmt.Sprintf("[%s] %s", cAnalyzer.Name, err))
mutex.Unlock()
if verbose {
fmt.Printf("Debug: %s completed with errors.\n", cAnalyzer.Name)
}
} else {
mutex.Lock()
a.Results = append(a.Results, result)
mutex.Unlock()
if verbose {
fmt.Printf("Debug: %s completed without errors.\n", cAnalyzer.Name)
}
}
<-semaphore
}(cAnalyzer, &wg, semaphore)
@@ -219,6 +293,7 @@ func (a *Analysis) RunCustomAnalysis() {
func (a *Analysis) RunAnalysis() {
activeFilters := viper.GetStringSlice("active_filters")
verbose := viper.GetBool("verbose")
coreAnalyzerMap, analyzerMap := analyzer.GetAnalyzerMap()
@@ -227,7 +302,13 @@ func (a *Analysis) RunAnalysis() {
if a.WithDoc {
var openApiErr error
if verbose {
fmt.Println("Debug: Fetching Kubernetes docs.")
}
openapiSchema, openApiErr = a.Client.Client.Discovery().OpenAPISchema()
if verbose {
fmt.Println("Debug: Checking Kubernetes docs.")
}
if openApiErr != nil {
a.Errors = append(a.Errors, fmt.Sprintf("[KubernetesDoc] %s", openApiErr))
}
@@ -242,11 +323,23 @@ func (a *Analysis) RunAnalysis() {
OpenapiSchema: openapiSchema,
}
semaphore := make(chan struct{}, a.MaxConcurrency)
// Set a reasonable maximum for concurrency to prevent excessive memory allocation
const maxAllowedConcurrency = 100
concurrency := a.MaxConcurrency
if concurrency <= 0 {
concurrency = 10 // Default value if not set
} else if concurrency > maxAllowedConcurrency {
concurrency = maxAllowedConcurrency // Cap at a reasonable maximum
}
semaphore := make(chan struct{}, concurrency)
var wg sync.WaitGroup
var mutex sync.Mutex
// if there are no filters selected and no active_filters then run coreAnalyzer
if len(a.Filters) == 0 && len(activeFilters) == 0 {
if verbose {
fmt.Println("Debug: No filters selected and no active filters found, run all core analyzers.")
}
for name, analyzer := range coreAnalyzerMap {
wg.Add(1)
semaphore <- struct{}{}
@@ -258,6 +351,9 @@ func (a *Analysis) RunAnalysis() {
}
// if the filters flag is specified
if len(a.Filters) != 0 {
if verbose {
fmt.Printf("Debug: Filter flags %v specified, run selected core analyzers.\n", a.Filters)
}
for _, filter := range a.Filters {
if analyzer, ok := analyzerMap[filter]; ok {
semaphore <- struct{}{}
@@ -272,6 +368,9 @@ func (a *Analysis) RunAnalysis() {
}
// use active_filters
if len(activeFilters) > 0 && verbose {
fmt.Printf("Debug: Found active filters %v, run selected core analyzers.\n", activeFilters)
}
for _, filter := range activeFilters {
if analyzer, ok := analyzerMap[filter]; ok {
semaphore <- struct{}{}
@@ -294,6 +393,10 @@ func (a *Analysis) executeAnalyzer(analyzer common.IAnalyzer, filter string, ana
}
// Run the analyzer
verbose := viper.GetBool("verbose")
if verbose {
fmt.Printf("Debug: %s launched.\n", reflect.TypeOf(analyzer).Name())
}
results, err := analyzer.Analyze(analyzerConfig)
if err != nil {
fmt.Println(err)
@@ -315,11 +418,17 @@ func (a *Analysis) executeAnalyzer(analyzer common.IAnalyzer, filter string, ana
a.Stats = append(a.Stats, stat)
}
a.Errors = append(a.Errors, fmt.Sprintf("[%s] %s", filter, err))
if verbose {
fmt.Printf("Debug: %s completed with errors.\n", reflect.TypeOf(analyzer).Name())
}
} else {
if a.WithStats {
a.Stats = append(a.Stats, stat)
}
a.Results = append(a.Results, results...)
if verbose {
fmt.Printf("Debug: %s completed without errors.\n", reflect.TypeOf(analyzer).Name())
}
}
<-semaphore
}
@@ -329,6 +438,11 @@ func (a *Analysis) GetAIResults(output string, anonymize bool) error {
return nil
}
verbose := viper.GetBool("verbose")
if verbose {
fmt.Println("Debug: Generating AI analysis.")
}
var bar *progressbar.ProgressBar
if output != "json" {
bar = progressbar.Default(int64(len(a.Results)))
@@ -337,6 +451,10 @@ func (a *Analysis) GetAIResults(output string, anonymize bool) error {
for index, analysis := range a.Results {
var texts []string
if bar != nil && verbose {
bar.Describe(fmt.Sprintf("Analyzing %s", analysis.Kind))
}
for _, failure := range analysis.Error {
if anonymize {
for _, s := range failure.Sensitive {

View File

@@ -17,13 +17,17 @@ import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"github.com/agiledragon/gomonkey/v2"
"github.com/k8sgpt-ai/k8sgpt/pkg/ai"
"github.com/k8sgpt-ai/k8sgpt/pkg/analyzer"
"github.com/k8sgpt-ai/k8sgpt/pkg/cache"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
"github.com/k8sgpt-ai/k8sgpt/pkg/util"
"github.com/magiconair/properties/assert"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
@@ -31,9 +35,15 @@ import (
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/rest"
)
// sub-function
// helper function: get type name of an analyzer
func getTypeName(i interface{}) string {
return reflect.TypeOf(i).Name()
}
// helper function: run analysis with filter
func analysis_RunAnalysisFilterTester(t *testing.T, filterFlag string) []common.Result {
clientset := fake.NewSimpleClientset(
&v1.Pod{
@@ -404,3 +414,252 @@ func TestGetAIResultForSanitizedFailures(t *testing.T) {
})
}
}
// Test: Verbose output in NewAnalysis with explain=false
func TestVerbose_NewAnalysisWithoutExplain(t *testing.T) {
// Set viper config.
viper.Set("verbose", true)
viper.Set("kubecontext", "dummy")
viper.Set("kubeconfig", "dummy")
// Patch kubernetes.NewClient to return a dummy client.
patches := gomonkey.ApplyFunc(kubernetes.NewClient, func(kubecontext, kubeconfig string) (*kubernetes.Client, error) {
return &kubernetes.Client{
Config: &rest.Config{Host: "fake-server"},
}, nil
})
defer patches.Reset()
output := util.CaptureOutput(func() {
a, err := NewAnalysis(
"", "english", []string{"Pod"}, "default", "", true,
false, // explain
10, false, false, []string{}, false,
)
require.NoError(t, err)
a.Close()
})
expectedOutputs := []string{
"Debug: Checking kubernetes client initialization.",
"Debug: Kubernetes client initialized, server=fake-server.",
"Debug: Checking cache configuration.",
"Debug: Cache configuration loaded, type=file.",
"Debug: Cache disabled.",
"Debug: Analysis configuration loaded, filters=[Pod], language=english, namespace=default, labelSelector=none, explain=false, maxConcurrency=10, withDoc=false, withStats=false.",
}
for _, expected := range expectedOutputs {
if !util.Contains(output, expected) {
t.Errorf("Expected output to contain: '%s', but got output: '%s'", expected, output)
}
}
}
// Test: Verbose output in NewAnalysis with explain=true
func TestVerbose_NewAnalysisWithExplain(t *testing.T) {
// Set viper config.
viper.Set("verbose", true)
viper.Set("kubecontext", "dummy")
viper.Set("kubeconfig", "dummy")
// Set a dummy AI configuration.
dummyAIConfig := map[string]interface{}{
"defaultProvider": "dummy",
"providers": []map[string]interface{}{
{
"name": "dummy",
"baseUrl": "http://dummy",
"model": "dummy-model",
"customHeaders": map[string]string{},
},
},
}
viper.Set("ai", dummyAIConfig)
// Patch kubernetes.NewClient to return a dummy client.
patches := gomonkey.ApplyFunc(kubernetes.NewClient, func(kubecontext, kubeconfig string) (*kubernetes.Client, error) {
return &kubernetes.Client{
Config: &rest.Config{Host: "fake-server"},
}, nil
})
defer patches.Reset()
// Patch ai.NewClient to return a NoOp client.
patches2 := gomonkey.ApplyFunc(ai.NewClient, func(name string) ai.IAI {
return &ai.NoOpAIClient{}
})
defer patches2.Reset()
output := util.CaptureOutput(func() {
a, err := NewAnalysis(
"", "english", []string{"Pod"}, "default", "", true,
true, // explain
10, false, false, []string{}, false,
)
require.NoError(t, err)
a.Close()
})
expectedOutputs := []string{
"Debug: Checking AI configuration.",
"Debug: Using default AI provider dummy.",
"Debug: AI configuration loaded, provider=dummy, baseUrl=http://dummy, model=dummy-model.",
"Debug: Checking AI client initialization.",
"Debug: AI client initialized.",
}
for _, expected := range expectedOutputs {
if !util.Contains(output, expected) {
t.Errorf("Expected output to contain: '%s', but got output: '%s'", expected, output)
}
}
}
// Test: Verbose output in RunAnalysis with filter flag
func TestVerbose_RunAnalysisWithFilter(t *testing.T) {
viper.Set("verbose", true)
// Run analysis with a filter flag ("Pod") to trigger debug output.
output := util.CaptureOutput(func() {
_ = analysis_RunAnalysisFilterTester(t, "Pod")
})
expectedOutputs := []string{
"Debug: Filter flags [Pod] specified, run selected core analyzers.",
"Debug: PodAnalyzer launched.",
"Debug: PodAnalyzer completed without errors.",
}
for _, expected := range expectedOutputs {
if !util.Contains(output, expected) {
t.Errorf("Expected output to contain: '%s', but got output: '%s'", expected, output)
}
}
}
// Test: Verbose output in RunAnalysis with active filter
func TestVerbose_RunAnalysisWithActiveFilter(t *testing.T) {
viper.Set("verbose", true)
viper.SetDefault("active_filters", "Ingress")
output := util.CaptureOutput(func() {
_ = analysis_RunAnalysisFilterTester(t, "")
})
expectedOutputs := []string{
"Debug: Found active filters [Ingress], run selected core analyzers.",
"Debug: IngressAnalyzer launched.",
"Debug: IngressAnalyzer completed without errors.",
}
for _, expected := range expectedOutputs {
if !util.Contains(output, expected) {
t.Errorf("Expected output to contain: '%s', but got output: '%s'", expected, output)
}
}
}
// Test: Verbose output in RunAnalysis without any filter (run all core analyzers)
func TestVerbose_RunAnalysisWithoutFilter(t *testing.T) {
viper.Set("verbose", true)
// Clear filter flag and active_filters to run all core analyzers.
viper.SetDefault("active_filters", []string{})
output := util.CaptureOutput(func() {
_ = analysis_RunAnalysisFilterTester(t, "")
})
// Check for debug message indicating no filters.
expectedNoFilter := "Debug: No filters selected and no active filters found, run all core analyzers."
if !util.Contains(output, expectedNoFilter) {
t.Errorf("Expected output to contain: '%s', but got output: '%s'", expectedNoFilter, output)
}
// Get all core analyzers from analyzer.GetAnalyzerMap()
coreAnalyzerMap, _ := analyzer.GetAnalyzerMap()
for _, analyzerInstance := range coreAnalyzerMap {
analyzerType := getTypeName(analyzerInstance)
expectedLaunched := fmt.Sprintf("Debug: %s launched.", analyzerType)
expectedCompleted := fmt.Sprintf("Debug: %s completed without errors.", analyzerType)
if !util.Contains(output, expectedLaunched) {
t.Errorf("Expected output to contain: '%s', but got output: '%s'", expectedLaunched, output)
}
if !util.Contains(output, expectedCompleted) {
t.Errorf("Expected output to contain: '%s', but got output: '%s'", expectedCompleted, output)
}
}
}
// Test: Verbose output in RunCustomAnalysis without custom analyzer
func TestVerbose_RunCustomAnalysisWithoutCustomAnalyzer(t *testing.T) {
viper.Set("verbose", true)
// Set custom_analyzers to empty array to trigger "No custom analyzers" debug message.
viper.Set("custom_analyzers", []interface{}{})
analysisObj := &Analysis{
MaxConcurrency: 1,
}
output := util.CaptureOutput(func() {
analysisObj.RunCustomAnalysis()
})
expected := "Debug: No custom analyzers found."
if !util.Contains(output, "Debug: No custom analyzers found.") {
t.Errorf("Expected output to contain: '%s', but got output: '%s'", expected, output)
}
}
// Test: Verbose output in RunCustomAnalysis with custom analyzer
func TestVerbose_RunCustomAnalysisWithCustomAnalyzer(t *testing.T) {
viper.Set("verbose", true)
// Set custom_analyzers with one custom analyzer using "fake" connection.
viper.Set("custom_analyzers", []map[string]interface{}{
{
"name": "TestCustomAnalyzer",
"connection": map[string]interface{}{"url": "127.0.0.1", "port": "2333"},
},
})
analysisObj := &Analysis{
MaxConcurrency: 1,
}
output := util.CaptureOutput(func() {
analysisObj.RunCustomAnalysis()
})
assert.Equal(t, 1, len(analysisObj.Errors)) // connection error
expectedOutputs := []string{
"Debug: Found custom analyzers [TestCustomAnalyzer].",
"Debug: TestCustomAnalyzer launched.",
"Debug: TestCustomAnalyzer completed with errors.",
}
for _, expected := range expectedOutputs {
if !util.Contains(output, expected) {
t.Errorf("Expected output to contain: '%s', but got output: '%s'", expected, output)
}
}
}
// Test: Verbose output in GetAIResults
func TestVerbose_GetAIResults(t *testing.T) {
viper.Set("verbose", true)
disabledCache := cache.New("disabled-cache")
disabledCache.DisableCache()
aiClient := &ai.NoOpAIClient{}
analysisObj := Analysis{
AIClient: aiClient,
Cache: disabledCache,
Results: []common.Result{
{
Kind: "Deployment",
Name: "test-deployment",
Error: []common.Failure{{Text: "test-problem", Sensitive: []common.Sensitive{}}},
Details: "test-solution",
ParentObject: "parent-resource",
},
},
Namespace: "default",
}
output := util.CaptureOutput(func() {
_ = analysisObj.GetAIResults("json", false)
})
expected := "Debug: Generating AI analysis."
if !util.Contains(output, expected) {
t.Errorf("Expected output to contain: '%s', but got output: '%s'", expected, output)
}
}

View File

@@ -43,16 +43,19 @@ var coreAnalyzerMap = map[string]common.IAnalyzer{
"Node": NodeAnalyzer{},
"ValidatingWebhookConfiguration": ValidatingWebhookAnalyzer{},
"MutatingWebhookConfiguration": MutatingWebhookAnalyzer{},
"ConfigMap": ConfigMapAnalyzer{},
}
var additionalAnalyzerMap = map[string]common.IAnalyzer{
"HorizontalPodAutoScaler": HpaAnalyzer{},
"HorizontalPodAutoscaler": HpaAnalyzer{},
"PodDisruptionBudget": PdbAnalyzer{},
"NetworkPolicy": NetworkPolicyAnalyzer{},
"Log": LogAnalyzer{},
"GatewayClass": GatewayClassAnalyzer{},
"Gateway": GatewayAnalyzer{},
"HTTPRoute": HTTPRouteAnalyzer{},
"Storage": StorageAnalyzer{},
"Security": SecurityAnalyzer{},
}
func ListFilters() ([]string, []string, []string) {

125
pkg/analyzer/configmap.go Normal file
View File

@@ -0,0 +1,125 @@
/*
Copyright 2024 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package analyzer
import (
"fmt"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type ConfigMapAnalyzer struct{}
func (ConfigMapAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
kind := "ConfigMap"
AnalyzerErrorsMetric.DeletePartialMatch(map[string]string{
"analyzer_name": kind,
})
// Get all ConfigMaps in the namespace
configMaps, err := a.Client.GetClient().CoreV1().ConfigMaps(a.Namespace).List(a.Context, metav1.ListOptions{
LabelSelector: a.LabelSelector,
})
if err != nil {
return nil, err
}
// Get all Pods to check ConfigMap usage
pods, err := a.Client.GetClient().CoreV1().Pods(a.Namespace).List(a.Context, metav1.ListOptions{})
if err != nil {
return nil, err
}
var results []common.Result
// Track which ConfigMaps are used
usedConfigMaps := make(map[string]bool)
configMapUsage := make(map[string][]string) // maps ConfigMap name to list of pods using it
// Analyze ConfigMap usage in Pods
for _, pod := range pods.Items {
// Check volume mounts
for _, volume := range pod.Spec.Volumes {
if volume.ConfigMap != nil {
usedConfigMaps[volume.ConfigMap.Name] = true
configMapUsage[volume.ConfigMap.Name] = append(configMapUsage[volume.ConfigMap.Name], pod.Name)
}
}
// Check environment variables
for _, container := range pod.Spec.Containers {
for _, env := range container.EnvFrom {
if env.ConfigMapRef != nil {
usedConfigMaps[env.ConfigMapRef.Name] = true
configMapUsage[env.ConfigMapRef.Name] = append(configMapUsage[env.ConfigMapRef.Name], pod.Name)
}
}
for _, env := range container.Env {
if env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil {
usedConfigMaps[env.ValueFrom.ConfigMapKeyRef.Name] = true
configMapUsage[env.ValueFrom.ConfigMapKeyRef.Name] = append(configMapUsage[env.ValueFrom.ConfigMapKeyRef.Name], pod.Name)
}
}
}
}
// Analyze each ConfigMap
for _, cm := range configMaps.Items {
var failures []common.Failure
// Check for unused ConfigMaps
if !usedConfigMaps[cm.Name] {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("ConfigMap %s is not used by any pods in the namespace", cm.Name),
Sensitive: []common.Sensitive{},
})
}
// Check for empty ConfigMaps
if len(cm.Data) == 0 && len(cm.BinaryData) == 0 {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("ConfigMap %s is empty", cm.Name),
Sensitive: []common.Sensitive{},
})
}
// Check for large ConfigMaps (over 1MB)
totalSize := 0
for _, value := range cm.Data {
totalSize += len(value)
}
for _, value := range cm.BinaryData {
totalSize += len(value)
}
if totalSize > 1024*1024 { // 1MB
failures = append(failures, common.Failure{
Text: fmt.Sprintf("ConfigMap %s is larger than 1MB (%d bytes)", cm.Name, totalSize),
Sensitive: []common.Sensitive{},
})
}
if len(failures) > 0 {
results = append(results, common.Result{
Kind: kind,
Name: fmt.Sprintf("%s/%s", cm.Namespace, cm.Name),
Error: failures,
})
AnalyzerErrorsMetric.WithLabelValues(kind, cm.Name, cm.Namespace).Set(float64(len(failures)))
}
}
return results, nil
}

View File

@@ -0,0 +1,149 @@
/*
Copyright 2024 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package analyzer
import (
"context"
"testing"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
"github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestConfigMapAnalyzer(t *testing.T) {
tests := []struct {
name string
namespace string
configMaps []v1.ConfigMap
pods []v1.Pod
expectedErrors int
}{
{
name: "unused configmap",
namespace: "default",
configMaps: []v1.ConfigMap{
{
ObjectMeta: metav1.ObjectMeta{
Name: "unused-cm",
Namespace: "default",
},
Data: map[string]string{
"key": "value",
},
},
},
expectedErrors: 1,
},
{
name: "empty configmap",
namespace: "default",
configMaps: []v1.ConfigMap{
{
ObjectMeta: metav1.ObjectMeta{
Name: "empty-cm",
Namespace: "default",
},
},
},
expectedErrors: 1,
},
{
name: "large configmap",
namespace: "default",
configMaps: []v1.ConfigMap{
{
ObjectMeta: metav1.ObjectMeta{
Name: "large-cm",
Namespace: "default",
},
Data: map[string]string{
"key": string(make([]byte, 1024*1024+1)), // 1MB + 1 byte
},
},
},
expectedErrors: 1,
},
{
name: "used configmap",
namespace: "default",
configMaps: []v1.ConfigMap{
{
ObjectMeta: metav1.ObjectMeta{
Name: "used-cm",
Namespace: "default",
},
Data: map[string]string{
"key": "value",
},
},
},
pods: []v1.Pod{
{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod",
Namespace: "default",
},
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "test-container",
EnvFrom: []v1.EnvFromSource{
{
ConfigMapRef: &v1.ConfigMapEnvSource{
LocalObjectReference: v1.LocalObjectReference{
Name: "used-cm",
},
},
},
},
},
},
},
},
},
expectedErrors: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := fake.NewSimpleClientset()
// Create test resources
for _, cm := range tt.configMaps {
_, err := client.CoreV1().ConfigMaps(tt.namespace).Create(context.TODO(), &cm, metav1.CreateOptions{})
assert.NoError(t, err)
}
for _, pod := range tt.pods {
_, err := client.CoreV1().Pods(tt.namespace).Create(context.TODO(), &pod, metav1.CreateOptions{})
assert.NoError(t, err)
}
analyzer := ConfigMapAnalyzer{}
results, err := analyzer.Analyze(common.Analyzer{
Client: &kubernetes.Client{Client: client},
Context: context.TODO(),
Namespace: tt.namespace,
})
assert.NoError(t, err)
assert.Equal(t, tt.expectedErrors, len(results))
})
}
}

View File

@@ -22,179 +22,274 @@ import (
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
"github.com/stretchr/testify/require"
batchv1 "k8s.io/api/batch/v1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestCronJobAnalyzer(t *testing.T) {
suspend := new(bool)
*suspend = true
invalidStartingDeadline := new(int64)
*invalidStartingDeadline = -7
validStartingDeadline := new(int64)
*validStartingDeadline = 7
config := common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "CJ1",
// This CronJob won't be list because of namespace filtering.
Namespace: "test",
},
},
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "CJ2",
Namespace: "default",
},
// A suspended CronJob will contribute to failures.
Spec: batchv1.CronJobSpec{
Suspend: suspend,
},
},
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "CJ3",
Namespace: "default",
},
Spec: batchv1.CronJobSpec{
// Valid schedule
Schedule: "*/1 * * * *",
// Negative starting deadline
StartingDeadlineSeconds: invalidStartingDeadline,
},
},
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "CJ4",
Namespace: "default",
},
Spec: batchv1.CronJobSpec{
// Invalid schedule
Schedule: "*** * * * *",
},
},
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "CJ5",
Namespace: "default",
},
Spec: batchv1.CronJobSpec{
// Valid schedule
Schedule: "*/1 * * * *",
// Positive starting deadline shouldn't be any problem.
StartingDeadlineSeconds: validStartingDeadline,
},
},
&batchv1.CronJob{
// This cronjob shouldn't contribute to any failures.
ObjectMeta: metav1.ObjectMeta{
Name: "successful-cronjob",
Namespace: "default",
Annotations: map[string]string{
"analysisDate": "2022-04-01",
},
Labels: map[string]string{
"app": "example-app",
},
},
Spec: batchv1.CronJobSpec{
Schedule: "*/1 * * * *",
ConcurrencyPolicy: "Allow",
JobTemplate: batchv1.JobTemplateSpec{
tests := []struct {
name string
config common.Analyzer
expectations []struct {
name string
failuresCount int
}
}{
{
name: "Suspended CronJob",
config: common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": "example-app",
},
Name: "suspended-job",
Namespace: "default",
},
Spec: batchv1.JobSpec{
Template: v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "example-container",
Image: "nginx",
},
},
RestartPolicy: v1.RestartPolicyOnFailure,
},
},
Spec: batchv1.CronJobSpec{
Schedule: "*/5 * * * *",
Suspend: boolPtr(true),
},
},
},
),
},
),
Context: context.Background(),
Namespace: "default",
},
expectations: []struct {
name string
failuresCount int
}{
{
name: "default/suspended-job",
failuresCount: 1, // One failure for being suspended
},
},
},
{
name: "Invalid schedule format",
config: common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "invalid-schedule",
Namespace: "default",
},
Spec: batchv1.CronJobSpec{
Schedule: "invalid-cron", // Invalid cron format
},
},
),
},
Context: context.Background(),
Namespace: "default",
},
expectations: []struct {
name string
failuresCount int
}{
{
name: "default/invalid-schedule",
failuresCount: 1, // One failure for invalid schedule
},
},
},
{
name: "Negative starting deadline",
config: common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "negative-deadline",
Namespace: "default",
},
Spec: batchv1.CronJobSpec{
Schedule: "*/5 * * * *",
StartingDeadlineSeconds: int64Ptr(-60), // Negative deadline
},
},
),
},
Context: context.Background(),
Namespace: "default",
},
expectations: []struct {
name string
failuresCount int
}{
{
name: "default/negative-deadline",
failuresCount: 1, // One failure for negative deadline
},
},
},
{
name: "Valid CronJob",
config: common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "valid-job",
Namespace: "default",
},
Spec: batchv1.CronJobSpec{
Schedule: "*/5 * * * *", // Valid cron format
},
},
),
},
Context: context.Background(),
Namespace: "default",
},
expectations: []struct {
name string
failuresCount int
}{
// No expectations for valid job
},
},
{
name: "Multiple issues",
config: common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "multiple-issues",
Namespace: "default",
},
Spec: batchv1.CronJobSpec{
Schedule: "invalid-cron",
StartingDeadlineSeconds: int64Ptr(-60),
},
},
),
},
Context: context.Background(),
Namespace: "default",
},
expectations: []struct {
name string
failuresCount int
}{
{
name: "default/multiple-issues",
failuresCount: 2, // Two failures: invalid schedule and negative deadline
},
},
},
Context: context.Background(),
Namespace: "default",
}
cjAnalyzer := CronJobAnalyzer{}
results, err := cjAnalyzer.Analyze(config)
require.NoError(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
analyzer := CronJobAnalyzer{}
results, err := analyzer.Analyze(tt.config)
require.NoError(t, err)
require.Len(t, results, len(tt.expectations))
sort.Slice(results, func(i, j int) bool {
return results[i].Name < results[j].Name
})
// Sort results by name for consistent comparison
sort.Slice(results, func(i, j int) bool {
return results[i].Name < results[j].Name
})
expectations := []string{
"default/CJ2",
"default/CJ3",
"default/CJ4",
}
require.Equal(t, len(expectations), len(results))
for i, result := range results {
require.Equal(t, expectations[i], result.Name)
for i, expectation := range tt.expectations {
require.Equal(t, expectation.name, results[i].Name)
require.Len(t, results[i].Error, expectation.failuresCount)
}
})
}
}
func TestCronJobAnalyzerLabelSelectorFiltering(t *testing.T) {
suspend := new(bool)
*suspend = true
invalidStartingDeadline := new(int64)
*invalidStartingDeadline = -7
validStartingDeadline := new(int64)
*validStartingDeadline = 7
func TestCronJobAnalyzerLabelSelector(t *testing.T) {
clientSet := fake.NewSimpleClientset(
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "job-with-label",
Namespace: "default",
Labels: map[string]string{
"app": "test",
},
},
Spec: batchv1.CronJobSpec{
Schedule: "invalid-cron", // This should trigger a failure
},
},
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "job-without-label",
Namespace: "default",
},
Spec: batchv1.CronJobSpec{
Schedule: "invalid-cron", // This should trigger a failure
},
},
)
// Test with label selector
config := common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "CJ1",
Namespace: "default",
Labels: map[string]string{
"app": "cronjob",
},
},
},
&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: "CJ2",
Namespace: "default",
},
},
),
Client: clientSet,
},
Context: context.Background(),
Namespace: "default",
LabelSelector: "app=cronjob",
LabelSelector: "app=test",
}
cjAnalyzer := CronJobAnalyzer{}
results, err := cjAnalyzer.Analyze(config)
analyzer := CronJobAnalyzer{}
results, err := analyzer.Analyze(config)
require.NoError(t, err)
require.Equal(t, 1, len(results))
require.Equal(t, "default/CJ1", results[0].Name)
require.Equal(t, "default/job-with-label", results[0].Name)
}
func TestCheckCronScheduleIsValid(t *testing.T) {
tests := []struct {
name string
schedule string
wantErr bool
}{
{
name: "Valid schedule - every 5 minutes",
schedule: "*/5 * * * *",
wantErr: false,
},
{
name: "Valid schedule - specific time",
schedule: "0 2 * * *",
wantErr: false,
},
{
name: "Valid schedule - complex",
schedule: "0 0 1,15 * 3",
wantErr: false,
},
{
name: "Invalid schedule - wrong format",
schedule: "invalid-cron",
wantErr: true,
},
{
name: "Invalid schedule - too many fields",
schedule: "* * * * * *",
wantErr: true,
},
{
name: "Invalid schedule - empty string",
schedule: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := CheckCronScheduleIsValid(tt.schedule)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
"github.com/k8sgpt-ai/k8sgpt/pkg/util"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -34,7 +35,7 @@ func (HpaAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
Kind: kind,
ApiVersion: schema.GroupVersion{
Group: "autoscaling",
Version: "v1",
Version: "v2",
},
OpenapiSchema: a.OpenapiSchema,
}
@@ -56,11 +57,22 @@ func (HpaAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
//check the error from status field
conditions := hpa.Status.Conditions
for _, condition := range conditions {
if condition.Status != "True" {
failures = append(failures, common.Failure{
Text: condition.Message,
Sensitive: []common.Sensitive{},
})
// https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale-walkthrough/#appendix-horizontal-pod-autoscaler-status-conditions
switch condition.Type {
case autoscalingv2.ScalingLimited:
if condition.Status == corev1.ConditionTrue {
failures = append(failures, common.Failure{
Text: condition.Message,
Sensitive: []common.Sensitive{},
})
}
default:
if condition.Status == corev1.ConditionFalse {
failures = append(failures, common.Failure{
Text: condition.Message,
Sensitive: []common.Sensitive{},
})
}
}
}

View File

@@ -735,3 +735,87 @@ func TestHPAAnalyzerStatusField(t *testing.T) {
assert.Equal(t, len(analysisResults), 1)
}
func TestHPAAnalyzerStatusScalingLimitedError(t *testing.T) {
clientset := fake.NewSimpleClientset(
&autoscalingv2.HorizontalPodAutoscaler{
ObjectMeta: metav1.ObjectMeta{
Name: "example",
Namespace: "default",
Annotations: map[string]string{},
},
Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{
Kind: "Deployment",
Name: "example",
},
},
Status: autoscalingv2.HorizontalPodAutoscalerStatus{
Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{
{
Type: autoscalingv2.AbleToScale,
Status: "True",
Message: "recommended size matches current size",
},
{
Type: autoscalingv2.ScalingActive,
Status: "True",
Message: "the HPA was able to successfully calculate a replica count",
},
{
Type: autoscalingv2.ScalingLimited,
Status: "True",
Message: "the desired replica count is less than the minimum replica count",
},
},
},
},
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "example",
Namespace: "default",
Annotations: map[string]string{},
},
Spec: appsv1.DeploymentSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "example",
Image: "nginx",
},
},
},
},
},
},
)
hpaAnalyzer := HpaAnalyzer{}
config := common.Analyzer{
Client: &kubernetes.Client{
Client: clientset,
},
Context: context.Background(),
Namespace: "default",
}
analysisResults, err := hpaAnalyzer.Analyze(config)
if err != nil {
t.Error(err)
}
var errorFound bool
want := "the desired replica count is less than the minimum replica count"
for _, analysis := range analysisResults {
for _, got := range analysis.Error {
if want == got.Text {
errorFound = true
}
}
if errorFound {
break
}
}
if !errorFound {
t.Errorf("Expected message, <%v> , not found in HorizontalPodAutoscaler's analysis results", want)
}
}

View File

@@ -15,226 +15,243 @@ package analyzer
import (
"context"
"sort"
"testing"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestIngressAnalyzer(t *testing.T) {
validIgClassName := new(string)
*validIgClassName = "valid-ingress-class"
var igRule networkingv1.IngressRule
httpRule := networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{
Path: "/",
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
// This service exists.
Name: "Service1",
},
},
},
{
Path: "/test1",
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
// This service is in the test namespace
// Hence, it won't be discovered.
Name: "Service2",
},
},
},
{
Path: "/test2",
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
// This service doesn't exist.
Name: "Service3",
},
},
},
},
}
igRule.IngressRuleValue.HTTP = &httpRule
config := common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&networkingv1.Ingress{
// Doesn't specify an ingress class.
ObjectMeta: metav1.ObjectMeta{
Name: "Ingress1",
Namespace: "default",
},
},
&networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "Ingress2",
Namespace: "default",
// Specify an invalid ingress class name using annotations.
Annotations: map[string]string{
"kubernetes.io/ingress.class": "invalid-class",
},
},
},
&networkingv1.Ingress{
// Namespace filtering.
ObjectMeta: metav1.ObjectMeta{
Name: "Ingress3",
Namespace: "test",
},
},
&networkingv1.IngressClass{
ObjectMeta: metav1.ObjectMeta{
Name: *validIgClassName,
},
},
&networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "Ingress4",
Namespace: "default",
// Specify valid ingress class name using annotations.
Annotations: map[string]string{
"kubernetes.io/ingress.class": *validIgClassName,
},
},
},
&v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "Service1",
Namespace: "default",
},
},
&v1.Service{
ObjectMeta: metav1.ObjectMeta{
// Namespace filtering.
Name: "Service2",
Namespace: "test",
},
},
&v1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "Secret1",
Namespace: "default",
},
},
&v1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "Secret2",
Namespace: "test",
},
},
&networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "Ingress5",
Namespace: "default",
},
// Specify valid ingress class name in spec.
Spec: networkingv1.IngressSpec{
IngressClassName: validIgClassName,
Rules: []networkingv1.IngressRule{
igRule,
},
TLS: []networkingv1.IngressTLS{
{
// This won't contribute to any failures.
SecretName: "Secret1",
},
{
// This secret won't be discovered because of namespace filtering.
SecretName: "Secret2",
},
{
// This secret doesn't exist.
SecretName: "Secret3",
},
},
},
},
),
},
Context: context.Background(),
Namespace: "default",
}
igAnalyzer := IngressAnalyzer{}
results, err := igAnalyzer.Analyze(config)
require.NoError(t, err)
sort.Slice(results, func(i, j int) bool {
return results[i].Name < results[j].Name
})
expectations := []struct {
name string
failuresCount int
// Create test cases
testCases := []struct {
name string
ingress *networkingv1.Ingress
expectedIssues []string
}{
{
name: "default/Ingress1",
failuresCount: 1,
name: "Non-existent backend service",
ingress: &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "default",
},
Spec: networkingv1.IngressSpec{
Rules: []networkingv1.IngressRule{
{
Host: "example.com",
IngressRuleValue: networkingv1.IngressRuleValue{
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{
Path: "/",
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "non-existent-service",
Port: networkingv1.ServiceBackendPort{
Number: 80,
},
},
},
},
},
},
},
},
},
},
},
expectedIssues: []string{
"Ingress default/test-ingress does not specify an Ingress class.",
"Ingress uses the service default/non-existent-service which does not exist.",
},
},
{
name: "default/Ingress2",
failuresCount: 1,
name: "Non-existent TLS secret",
ingress: &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress-tls",
Namespace: "default",
},
Spec: networkingv1.IngressSpec{
TLS: []networkingv1.IngressTLS{
{
Hosts: []string{"example.com"},
SecretName: "non-existent-secret",
},
},
Rules: []networkingv1.IngressRule{
{
Host: "example.com",
IngressRuleValue: networkingv1.IngressRuleValue{
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{
Path: "/",
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "test-service",
Port: networkingv1.ServiceBackendPort{
Number: 80,
},
},
},
},
},
},
},
},
},
},
},
expectedIssues: []string{
"Ingress default/test-ingress-tls does not specify an Ingress class.",
"Ingress uses the service default/test-service which does not exist.",
"Ingress uses the secret default/non-existent-secret as a TLS certificate which does not exist.",
},
},
{
name: "default/Ingress5",
failuresCount: 4,
name: "Multiple issues",
ingress: &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress-multi",
Namespace: "default",
},
Spec: networkingv1.IngressSpec{
TLS: []networkingv1.IngressTLS{
{
Hosts: []string{"example.com"},
SecretName: "non-existent-secret",
},
},
Rules: []networkingv1.IngressRule{
{
Host: "example.com",
IngressRuleValue: networkingv1.IngressRuleValue{
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{
Path: "/",
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "non-existent-service",
Port: networkingv1.ServiceBackendPort{
Number: 80,
},
},
},
},
},
},
},
},
},
},
},
expectedIssues: []string{
"Ingress default/test-ingress-multi does not specify an Ingress class.",
"Ingress uses the service default/non-existent-service which does not exist.",
"Ingress uses the secret default/non-existent-secret as a TLS certificate which does not exist.",
},
},
}
require.Equal(t, len(expectations), len(results))
// Run test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create a new context and clientset for each test case
ctx := context.Background()
clientset := fake.NewSimpleClientset()
for i, result := range results {
require.Equal(t, expectations[i].name, result.Name)
require.Equal(t, expectations[i].failuresCount, len(result.Error))
// Create the ingress in the fake clientset
_, err := clientset.NetworkingV1().Ingresses(tc.ingress.Namespace).Create(ctx, tc.ingress, metav1.CreateOptions{})
assert.NoError(t, err)
// Create the analyzer configuration
config := common.Analyzer{
Client: &kubernetes.Client{
Client: clientset,
},
Context: ctx,
Namespace: tc.ingress.Namespace,
}
// Create the analyzer and run analysis
analyzer := IngressAnalyzer{}
results, err := analyzer.Analyze(config)
assert.NoError(t, err)
// Check that we got the expected number of issues
assert.Len(t, results, 1, "Expected 1 result")
result := results[0]
assert.Len(t, result.Error, len(tc.expectedIssues), "Expected %d issues, got %d", len(tc.expectedIssues), len(result.Error))
// Check that each expected issue is present
for _, expectedIssue := range tc.expectedIssues {
found := false
for _, failure := range result.Error {
if failure.Text == expectedIssue {
found = true
break
}
}
assert.True(t, found, "Expected to find issue: %s", expectedIssue)
}
})
}
}
func TestIngressAnalyzerLabelSelectorFiltering(t *testing.T) {
validIgClassName := new(string)
*validIgClassName = "valid-ingress-class"
func TestIngressAnalyzerLabelSelector(t *testing.T) {
clientSet := fake.NewSimpleClientset(
&networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "ingress-with-label",
Namespace: "default",
Labels: map[string]string{
"app": "test",
},
},
Spec: networkingv1.IngressSpec{
// Missing ingress class to trigger a failure
},
},
&networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "ingress-without-label",
Namespace: "default",
},
Spec: networkingv1.IngressSpec{
// Missing ingress class to trigger a failure
},
},
)
// Test with label selector
config := common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "Ingress1",
Namespace: "default",
Labels: map[string]string{
"app": "ingress",
},
},
},
&networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "Ingress2",
Namespace: "default",
},
},
),
Client: clientSet,
},
Context: context.Background(),
Namespace: "default",
LabelSelector: "app=ingress",
LabelSelector: "app=test",
}
igAnalyzer := IngressAnalyzer{}
results, err := igAnalyzer.Analyze(config)
analyzer := IngressAnalyzer{}
results, err := analyzer.Analyze(config)
require.NoError(t, err)
require.Equal(t, 1, len(results))
require.Equal(t, "default/Ingress1", results[0].Name)
require.Equal(t, "default/ingress-with-label", results[0].Name)
}
// Helper functions
func strPtr(s string) *string {
return &s
}
func pathTypePtr(p networkingv1.PathType) *networkingv1.PathType {
return &p
}

201
pkg/analyzer/security.go Normal file
View File

@@ -0,0 +1,201 @@
/*
Copyright 2024 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package analyzer
import (
"fmt"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type SecurityAnalyzer struct{}
func (SecurityAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
kind := "Security"
AnalyzerErrorsMetric.DeletePartialMatch(map[string]string{
"analyzer_name": kind,
})
var results []common.Result
// Analyze ServiceAccounts
saResults, err := analyzeServiceAccounts(a)
if err != nil {
return nil, err
}
results = append(results, saResults...)
// Analyze RoleBindings
rbResults, err := analyzeRoleBindings(a)
if err != nil {
return nil, err
}
results = append(results, rbResults...)
// Analyze Pod Security Contexts
podResults, err := analyzePodSecurityContexts(a)
if err != nil {
return nil, err
}
results = append(results, podResults...)
return results, nil
}
func analyzeServiceAccounts(a common.Analyzer) ([]common.Result, error) {
var results []common.Result
sas, err := a.Client.GetClient().CoreV1().ServiceAccounts(a.Namespace).List(a.Context, metav1.ListOptions{
LabelSelector: a.LabelSelector,
})
if err != nil {
return nil, err
}
for _, sa := range sas.Items {
var failures []common.Failure
// Check for default service account usage
if sa.Name == "default" {
pods, err := a.Client.GetClient().CoreV1().Pods(sa.Namespace).List(a.Context, metav1.ListOptions{})
if err != nil {
continue
}
defaultSAUsers := []string{}
for _, pod := range pods.Items {
if pod.Spec.ServiceAccountName == "default" {
defaultSAUsers = append(defaultSAUsers, pod.Name)
}
}
if len(defaultSAUsers) > 0 {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("Default service account is being used by pods: %v", defaultSAUsers),
Sensitive: []common.Sensitive{},
})
}
}
if len(failures) > 0 {
results = append(results, common.Result{
Kind: "Security/ServiceAccount",
Name: fmt.Sprintf("%s/%s", sa.Namespace, sa.Name),
Error: failures,
})
AnalyzerErrorsMetric.WithLabelValues("Security/ServiceAccount", sa.Name, sa.Namespace).Set(float64(len(failures)))
}
}
return results, nil
}
func analyzeRoleBindings(a common.Analyzer) ([]common.Result, error) {
var results []common.Result
rbs, err := a.Client.GetClient().RbacV1().RoleBindings(a.Namespace).List(a.Context, metav1.ListOptions{
LabelSelector: a.LabelSelector,
})
if err != nil {
return nil, err
}
for _, rb := range rbs.Items {
var failures []common.Failure
// Check for wildcards in role references
role, err := a.Client.GetClient().RbacV1().Roles(rb.Namespace).Get(a.Context, rb.RoleRef.Name, metav1.GetOptions{})
if err != nil {
continue
}
for _, rule := range role.Rules {
if containsWildcard(rule.Verbs) || containsWildcard(rule.Resources) {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("RoleBinding %s references Role %s which contains wildcard permissions - this is not recommended for security best practices", rb.Name, role.Name),
Sensitive: []common.Sensitive{},
})
}
}
if len(failures) > 0 {
results = append(results, common.Result{
Kind: "Security/RoleBinding",
Name: fmt.Sprintf("%s/%s", rb.Namespace, rb.Name),
Error: failures,
})
AnalyzerErrorsMetric.WithLabelValues("Security/RoleBinding", rb.Name, rb.Namespace).Set(float64(len(failures)))
}
}
return results, nil
}
func analyzePodSecurityContexts(a common.Analyzer) ([]common.Result, error) {
var results []common.Result
pods, err := a.Client.GetClient().CoreV1().Pods(a.Namespace).List(a.Context, metav1.ListOptions{
LabelSelector: a.LabelSelector,
})
if err != nil {
return nil, err
}
for _, pod := range pods.Items {
var failures []common.Failure
// Check for privileged containers first (most critical)
hasPrivilegedContainer := false
for _, container := range pod.Spec.Containers {
if container.SecurityContext != nil && container.SecurityContext.Privileged != nil && *container.SecurityContext.Privileged {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("Container %s in pod %s is running as privileged which poses security risks", container.Name, pod.Name),
Sensitive: []common.Sensitive{},
})
hasPrivilegedContainer = true
break
}
}
// Only check for missing security context if no privileged containers found
if !hasPrivilegedContainer && pod.Spec.SecurityContext == nil {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("Pod %s does not have a security context defined which may pose security risks", pod.Name),
Sensitive: []common.Sensitive{},
})
}
if len(failures) > 0 {
results = append(results, common.Result{
Kind: "Security/Pod",
Name: fmt.Sprintf("%s/%s", pod.Namespace, pod.Name),
Error: failures[:1],
})
AnalyzerErrorsMetric.WithLabelValues("Security/Pod", pod.Name, pod.Namespace).Set(1)
}
}
return results, nil
}
func containsWildcard(slice []string) bool {
for _, item := range slice {
if item == "*" {
return true
}
}
return false
}

View File

@@ -0,0 +1,181 @@
/*
Copyright 2024 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package analyzer
import (
"context"
"testing"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
"github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestSecurityAnalyzer(t *testing.T) {
tests := []struct {
name string
namespace string
serviceAccounts []v1.ServiceAccount
pods []v1.Pod
roles []rbacv1.Role
roleBindings []rbacv1.RoleBinding
expectedErrors int
expectedKinds []string
}{
{
name: "default service account usage",
namespace: "default",
serviceAccounts: []v1.ServiceAccount{
{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Namespace: "default",
},
},
},
pods: []v1.Pod{
{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod",
Namespace: "default",
},
Spec: v1.PodSpec{
ServiceAccountName: "default",
},
},
},
expectedErrors: 2,
expectedKinds: []string{"Security/ServiceAccount", "Security/Pod"},
},
{
name: "privileged container",
namespace: "default",
pods: []v1.Pod{
{
ObjectMeta: metav1.ObjectMeta{
Name: "privileged-pod",
Namespace: "default",
},
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "privileged-container",
SecurityContext: &v1.SecurityContext{
Privileged: boolPtr(true),
},
},
},
},
},
},
expectedErrors: 1,
expectedKinds: []string{"Security/Pod"},
},
{
name: "wildcard permissions in role",
namespace: "default",
roles: []rbacv1.Role{
{
ObjectMeta: metav1.ObjectMeta{
Name: "wildcard-role",
Namespace: "default",
},
Rules: []rbacv1.PolicyRule{
{
Verbs: []string{"*"},
Resources: []string{"pods"},
},
},
},
},
roleBindings: []rbacv1.RoleBinding{
{
ObjectMeta: metav1.ObjectMeta{
Name: "test-binding",
Namespace: "default",
},
RoleRef: rbacv1.RoleRef{
Kind: "Role",
Name: "wildcard-role",
},
},
},
expectedErrors: 1,
expectedKinds: []string{"Security/RoleBinding"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := fake.NewSimpleClientset()
// Create test resources
for _, sa := range tt.serviceAccounts {
_, err := client.CoreV1().ServiceAccounts(tt.namespace).Create(context.TODO(), &sa, metav1.CreateOptions{})
assert.NoError(t, err)
}
for _, pod := range tt.pods {
_, err := client.CoreV1().Pods(tt.namespace).Create(context.TODO(), &pod, metav1.CreateOptions{})
assert.NoError(t, err)
}
for _, role := range tt.roles {
_, err := client.RbacV1().Roles(tt.namespace).Create(context.TODO(), &role, metav1.CreateOptions{})
assert.NoError(t, err)
}
for _, rb := range tt.roleBindings {
_, err := client.RbacV1().RoleBindings(tt.namespace).Create(context.TODO(), &rb, metav1.CreateOptions{})
assert.NoError(t, err)
}
analyzer := SecurityAnalyzer{}
results, err := analyzer.Analyze(common.Analyzer{
Client: &kubernetes.Client{Client: client},
Context: context.TODO(),
Namespace: tt.namespace,
})
assert.NoError(t, err)
// Debug: Print all results
t.Logf("Got %d results:", len(results))
for _, result := range results {
t.Logf(" Kind: %s, Name: %s", result.Kind, result.Name)
for _, failure := range result.Error {
t.Logf(" Failure: %s", failure.Text)
}
}
// Count results by kind
resultsByKind := make(map[string]int)
for _, result := range results {
resultsByKind[result.Kind]++
}
// Check that we have the expected number of results for each kind
for _, expectedKind := range tt.expectedKinds {
assert.Equal(t, 1, resultsByKind[expectedKind], "Expected 1 result of kind %s", expectedKind)
}
// Check total number of results matches expected kinds
assert.Equal(t, len(tt.expectedKinds), len(results), "Expected %d total results", len(tt.expectedKinds))
})
}
}

View File

@@ -24,145 +24,232 @@ import (
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/tools/leaderelection/resourcelock"
)
func TestServiceAnalyzer(t *testing.T) {
config := common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&v1.Endpoints{
ObjectMeta: metav1.ObjectMeta{
Name: "Endpoint1",
Namespace: "test",
},
// Endpoint with non-zero subsets.
Subsets: []v1.EndpointSubset{
{
// These not ready end points will contribute to failures.
NotReadyAddresses: []v1.EndpointAddress{
{
TargetRef: &v1.ObjectReference{
Kind: "test-reference",
Name: "reference1",
},
},
{
TargetRef: &v1.ObjectReference{
Kind: "test-reference",
Name: "reference2",
},
},
},
},
{
// These not ready end points will contribute to failures.
NotReadyAddresses: []v1.EndpointAddress{
{
TargetRef: &v1.ObjectReference{
Kind: "test-reference",
Name: "reference3",
},
},
},
},
},
},
&v1.Endpoints{
ObjectMeta: metav1.ObjectMeta{
Name: "Endpoint2",
Namespace: "test",
Annotations: map[string]string{
// Leader election record annotation key defined.
resourcelock.LeaderElectionRecordAnnotationKey: "this is okay",
},
},
// Endpoint with zero subsets.
},
&v1.Endpoints{
ObjectMeta: metav1.ObjectMeta{
// This won't contribute to any failures.
Name: "non-existent-service",
Namespace: "test",
Annotations: map[string]string{},
},
// Endpoint with zero subsets.
},
&v1.Endpoints{
ObjectMeta: metav1.ObjectMeta{
Name: "Service1",
Namespace: "test",
Annotations: map[string]string{},
},
// Endpoint with zero subsets.
},
&v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "Service1",
Namespace: "test",
},
Spec: v1.ServiceSpec{
Selector: map[string]string{
"app1": "test-app1",
"app2": "test-app2",
},
},
},
&v1.Service{
ObjectMeta: metav1.ObjectMeta{
// This service won't be discovered.
Name: "Service2",
Namespace: "default",
},
Spec: v1.ServiceSpec{
Selector: map[string]string{
"app1": "test-app1",
"app2": "test-app2",
},
},
},
&v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "Service3",
Namespace: "test",
},
Spec: v1.ServiceSpec{
// No Spec Selector
},
},
),
},
Context: context.Background(),
Namespace: "test",
}
sAnalyzer := ServiceAnalyzer{}
results, err := sAnalyzer.Analyze(config)
require.NoError(t, err)
sort.Slice(results, func(i, j int) bool {
return results[i].Name < results[j].Name
})
expectations := []struct {
name string
failuresCount int
tests := []struct {
name string
config common.Analyzer
expectations []struct {
name string
failuresCount int
}
}{
{
name: "test/Endpoint1",
failuresCount: 1,
name: "Service with no endpoints",
config: common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&v1.Endpoints{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
Subsets: []v1.EndpointSubset{}, // Empty subsets
},
&v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
Spec: v1.ServiceSpec{
Selector: map[string]string{
"app": "test",
},
},
},
),
},
Namespace: "default",
},
expectations: []struct {
name string
failuresCount int
}{
{
name: "default/test-service",
failuresCount: 1, // One failure for no endpoints
},
},
},
{
name: "test/Service1",
failuresCount: 2,
name: "Service with not ready endpoints",
config: common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&v1.Endpoints{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
Subsets: []v1.EndpointSubset{
{
NotReadyAddresses: []v1.EndpointAddress{
{
TargetRef: &v1.ObjectReference{
Kind: "Pod",
Name: "test-pod",
},
},
},
},
},
},
&v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
Spec: v1.ServiceSpec{
Selector: map[string]string{
"app": "test",
},
},
},
),
},
Namespace: "default",
},
expectations: []struct {
name string
failuresCount int
}{
{
name: "default/test-service",
failuresCount: 1, // One failure for not ready endpoints
},
},
},
{
name: "Service with warning events",
config: common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&v1.Endpoints{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
Subsets: []v1.EndpointSubset{}, // Empty subsets
},
&v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
Spec: v1.ServiceSpec{
Selector: map[string]string{
"app": "test",
},
},
},
&v1.Event{
ObjectMeta: metav1.ObjectMeta{
Name: "test-event",
Namespace: "default",
},
InvolvedObject: v1.ObjectReference{
Kind: "Service",
Name: "test-service",
Namespace: "default",
},
Type: "Warning",
Reason: "TestReason",
Message: "Test warning message",
},
),
},
Namespace: "default",
},
expectations: []struct {
name string
failuresCount int
}{
{
name: "default/test-service",
failuresCount: 2, // One failure for no endpoints, one for warning event
},
},
},
{
name: "Service with leader election annotation",
config: common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&v1.Endpoints{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
Annotations: map[string]string{
"control-plane.alpha.kubernetes.io/leader": "test-leader",
},
},
Subsets: []v1.EndpointSubset{}, // Empty subsets
},
&v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
Spec: v1.ServiceSpec{
Selector: map[string]string{
"app": "test",
},
},
},
),
},
Namespace: "default",
},
expectations: []struct {
name string
failuresCount int
}{
// No expectations for leader election endpoints
},
},
{
name: "Service with non-existent service",
config: common.Analyzer{
Client: &kubernetes.Client{
Client: fake.NewSimpleClientset(
&v1.Endpoints{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service",
Namespace: "default",
},
Subsets: []v1.EndpointSubset{}, // Empty subsets
},
),
},
Namespace: "default",
},
expectations: []struct {
name string
failuresCount int
}{
// No expectations for non-existent service
},
},
}
require.Equal(t, len(expectations), len(results))
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
analyzer := ServiceAnalyzer{}
results, err := analyzer.Analyze(tt.config)
require.NoError(t, err)
require.Len(t, results, len(tt.expectations))
for i, result := range results {
require.Equal(t, expectations[i].name, result.Name)
require.Equal(t, expectations[i].failuresCount, len(result.Error))
// Sort results by name for consistent comparison
sort.Slice(results, func(i, j int) bool {
return results[i].Name < results[j].Name
})
for i, expectation := range tt.expectations {
require.Equal(t, expectation.name, results[i].Name)
require.Len(t, results[i].Error, expectation.failuresCount)
}
})
}
}

216
pkg/analyzer/storage.go Normal file
View File

@@ -0,0 +1,216 @@
/*
Copyright 2024 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package analyzer
import (
"fmt"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type StorageAnalyzer struct{}
func (StorageAnalyzer) Analyze(a common.Analyzer) ([]common.Result, error) {
kind := "Storage"
AnalyzerErrorsMetric.DeletePartialMatch(map[string]string{
"analyzer_name": kind,
})
var results []common.Result
// Analyze StorageClasses
scResults, err := analyzeStorageClasses(a)
if err != nil {
return nil, err
}
results = append(results, scResults...)
// Analyze PersistentVolumes
pvResults, err := analyzePersistentVolumes(a)
if err != nil {
return nil, err
}
results = append(results, pvResults...)
// Analyze PVCs with enhanced checks
pvcResults, err := analyzePersistentVolumeClaims(a)
if err != nil {
return nil, err
}
results = append(results, pvcResults...)
return results, nil
}
func analyzeStorageClasses(a common.Analyzer) ([]common.Result, error) {
var results []common.Result
scs, err := a.Client.GetClient().StorageV1().StorageClasses().List(a.Context, metav1.ListOptions{})
if err != nil {
return nil, err
}
for _, sc := range scs.Items {
var failures []common.Failure
// Check for deprecated storage classes
if sc.Provisioner == "kubernetes.io/no-provisioner" {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("StorageClass %s uses deprecated provisioner 'kubernetes.io/no-provisioner'", sc.Name),
Sensitive: []common.Sensitive{},
})
}
// Check for default storage class
if sc.Annotations["storageclass.kubernetes.io/is-default-class"] == "true" {
// Check if there are multiple default storage classes
defaultCount := 0
for _, otherSc := range scs.Items {
if otherSc.Annotations["storageclass.kubernetes.io/is-default-class"] == "true" {
defaultCount++
}
}
if defaultCount > 1 {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("Multiple default StorageClasses found (%d), which can cause confusion", defaultCount),
Sensitive: []common.Sensitive{},
})
}
}
if len(failures) > 0 {
results = append(results, common.Result{
Kind: "Storage/StorageClass",
Name: sc.Name,
Error: failures,
})
AnalyzerErrorsMetric.WithLabelValues("Storage/StorageClass", sc.Name, "").Set(float64(len(failures)))
}
}
return results, nil
}
func analyzePersistentVolumes(a common.Analyzer) ([]common.Result, error) {
var results []common.Result
pvs, err := a.Client.GetClient().CoreV1().PersistentVolumes().List(a.Context, metav1.ListOptions{})
if err != nil {
return nil, err
}
for _, pv := range pvs.Items {
var failures []common.Failure
// Check for released PVs
if pv.Status.Phase == v1.VolumeReleased {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("PersistentVolume %s is in Released state and should be cleaned up", pv.Name),
Sensitive: []common.Sensitive{},
})
}
// Check for failed PVs
if pv.Status.Phase == v1.VolumeFailed {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("PersistentVolume %s is in Failed state", pv.Name),
Sensitive: []common.Sensitive{},
})
}
// Check for small PVs (less than 1Gi)
if capacity, ok := pv.Spec.Capacity[v1.ResourceStorage]; ok {
if capacity.Cmp(resource.MustParse("1Gi")) < 0 {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("PersistentVolume %s has small capacity (%s)", pv.Name, capacity.String()),
Sensitive: []common.Sensitive{},
})
}
}
if len(failures) > 0 {
results = append(results, common.Result{
Kind: "Storage/PersistentVolume",
Name: pv.Name,
Error: failures,
})
AnalyzerErrorsMetric.WithLabelValues("Storage/PersistentVolume", pv.Name, "").Set(float64(len(failures)))
}
}
return results, nil
}
func analyzePersistentVolumeClaims(a common.Analyzer) ([]common.Result, error) {
var results []common.Result
pvcs, err := a.Client.GetClient().CoreV1().PersistentVolumeClaims(a.Namespace).List(a.Context, metav1.ListOptions{
LabelSelector: a.LabelSelector,
})
if err != nil {
return nil, err
}
for _, pvc := range pvcs.Items {
var failures []common.Failure
// Check for PVC state issues first (most critical)
switch pvc.Status.Phase {
case v1.ClaimPending:
failures = append(failures, common.Failure{
Text: fmt.Sprintf("PersistentVolumeClaim %s is in Pending state", pvc.Name),
Sensitive: []common.Sensitive{},
})
case v1.ClaimLost:
failures = append(failures, common.Failure{
Text: fmt.Sprintf("PersistentVolumeClaim %s is in Lost state", pvc.Name),
Sensitive: []common.Sensitive{},
})
default:
// Only check other issues if PVC is not in a critical state
if capacity, ok := pvc.Spec.Resources.Requests[v1.ResourceStorage]; ok {
if capacity.Cmp(resource.MustParse("1Gi")) < 0 {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("PersistentVolumeClaim %s has small capacity (%s)", pvc.Name, capacity.String()),
Sensitive: []common.Sensitive{},
})
}
}
// Check for missing storage class
if pvc.Spec.StorageClassName == nil && pvc.Spec.VolumeName == "" {
failures = append(failures, common.Failure{
Text: fmt.Sprintf("PersistentVolumeClaim %s has no StorageClass specified", pvc.Name),
Sensitive: []common.Sensitive{},
})
}
}
// Only report the first failure found
if len(failures) > 0 {
results = append(results, common.Result{
Kind: "Storage/PersistentVolumeClaim",
Name: fmt.Sprintf("%s/%s", pvc.Namespace, pvc.Name),
Error: failures[:1],
})
AnalyzerErrorsMetric.WithLabelValues("Storage/PersistentVolumeClaim", pvc.Name, pvc.Namespace).Set(1)
}
}
return results, nil
}

View File

@@ -0,0 +1,254 @@
/*
Copyright 2024 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package analyzer
import (
"context"
"testing"
"github.com/k8sgpt-ai/k8sgpt/pkg/common"
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
v1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestStorageAnalyzer(t *testing.T) {
tests := []struct {
name string
namespace string
storageClasses []storagev1.StorageClass
pvs []v1.PersistentVolume
pvcs []v1.PersistentVolumeClaim
expectedErrors int
}{
{
name: "Deprecated StorageClass",
namespace: "default",
storageClasses: []storagev1.StorageClass{
{
ObjectMeta: metav1.ObjectMeta{
Name: "deprecated-sc",
},
Provisioner: "kubernetes.io/no-provisioner",
},
},
expectedErrors: 1,
},
{
name: "Multiple Default StorageClasses",
namespace: "default",
storageClasses: []storagev1.StorageClass{
{
ObjectMeta: metav1.ObjectMeta{
Name: "default-sc1",
Annotations: map[string]string{
"storageclass.kubernetes.io/is-default-class": "true",
},
},
Provisioner: "kubernetes.io/gce-pd",
},
{
ObjectMeta: metav1.ObjectMeta{
Name: "default-sc2",
Annotations: map[string]string{
"storageclass.kubernetes.io/is-default-class": "true",
},
},
Provisioner: "kubernetes.io/aws-ebs",
},
},
expectedErrors: 2,
},
{
name: "Released PV",
namespace: "default",
pvs: []v1.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{
Name: "released-pv",
},
Status: v1.PersistentVolumeStatus{
Phase: v1.VolumeReleased,
},
},
},
expectedErrors: 1,
},
{
name: "Failed PV",
namespace: "default",
pvs: []v1.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{
Name: "failed-pv",
},
Status: v1.PersistentVolumeStatus{
Phase: v1.VolumeFailed,
},
},
},
expectedErrors: 1,
},
{
name: "Small PV",
namespace: "default",
pvs: []v1.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{
Name: "small-pv",
},
Spec: v1.PersistentVolumeSpec{
Capacity: v1.ResourceList{
v1.ResourceStorage: resource.MustParse("500Mi"),
},
},
},
},
expectedErrors: 1,
},
{
name: "Pending PVC",
namespace: "default",
pvcs: []v1.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{
Name: "pending-pvc",
Namespace: "default",
},
Status: v1.PersistentVolumeClaimStatus{
Phase: v1.ClaimPending,
},
},
},
expectedErrors: 1,
},
{
name: "Lost PVC",
namespace: "default",
pvcs: []v1.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{
Name: "lost-pvc",
Namespace: "default",
},
Status: v1.PersistentVolumeClaimStatus{
Phase: v1.ClaimLost,
},
},
},
expectedErrors: 1,
},
{
name: "Small PVC",
namespace: "default",
pvcs: []v1.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{
Name: "small-pvc",
Namespace: "default",
},
Spec: v1.PersistentVolumeClaimSpec{
Resources: v1.VolumeResourceRequirements{
Requests: v1.ResourceList{
v1.ResourceStorage: resource.MustParse("500Mi"),
},
},
},
},
},
expectedErrors: 1,
},
{
name: "PVC without StorageClass",
namespace: "default",
pvcs: []v1.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{
Name: "no-sc-pvc",
Namespace: "default",
},
Spec: v1.PersistentVolumeClaimSpec{
Resources: v1.VolumeResourceRequirements{
Requests: v1.ResourceList{
v1.ResourceStorage: resource.MustParse("1Gi"),
},
},
},
},
},
expectedErrors: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create fake client
client := fake.NewSimpleClientset()
// Create test resources
for _, sc := range tt.storageClasses {
_, err := client.StorageV1().StorageClasses().Create(context.TODO(), &sc, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create StorageClass: %v", err)
}
}
for _, pv := range tt.pvs {
_, err := client.CoreV1().PersistentVolumes().Create(context.TODO(), &pv, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create PV: %v", err)
}
}
for _, pvc := range tt.pvcs {
_, err := client.CoreV1().PersistentVolumeClaims(tt.namespace).Create(context.TODO(), &pvc, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create PVC: %v", err)
}
}
// Create analyzer
analyzer := StorageAnalyzer{}
// Create analyzer config
config := common.Analyzer{
Client: &kubernetes.Client{
Client: client,
},
Context: context.TODO(),
Namespace: tt.namespace,
}
// Run analysis
results, err := analyzer.Analyze(config)
if err != nil {
t.Fatalf("Failed to run analysis: %v", err)
}
// Count total errors
totalErrors := 0
for _, result := range results {
totalErrors += len(result.Error)
}
// Check error count
if totalErrors != tt.expectedErrors {
t.Errorf("Expected %d errors, got %d", tt.expectedErrors, totalErrors)
}
})
}
}

View File

@@ -0,0 +1,10 @@
package analyzer
// Helper functions for tests
func boolPtr(b bool) *bool {
return &b
}
func int64Ptr(i int64) *int64 {
return &i
}

View File

@@ -1,12 +1,15 @@
package cache
import (
rpc "buf.build/gen/go/interplex-ai/schemas/grpc/go/protobuf/schema/v1/schemav1grpc"
schemav1 "buf.build/gen/go/interplex-ai/schemas/protocolbuffers/go/protobuf/schema/v1"
"context"
"errors"
"google.golang.org/grpc"
"fmt"
"os"
rpc "buf.build/gen/go/interplex-ai/schemas/grpc/go/protobuf/schema/v1/schemav1grpc"
schemav1 "buf.build/gen/go/interplex-ai/schemas/protocolbuffers/go/protobuf/schema/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
var _ ICache = (*InterplexCache)(nil)
@@ -59,6 +62,10 @@ func (c *InterplexCache) Store(key string, data string) error {
}
func (c *InterplexCache) Load(key string) (string, error) {
if os.Getenv("INTERPLEX_LOCAL_MODE") != "" {
c.configuration.ConnectionString = "localhost:8084"
}
conn, err := grpc.NewClient(c.configuration.ConnectionString, grpc.WithInsecure(), grpc.WithBlock())
defer conn.Close()
if err != nil {
@@ -70,36 +77,52 @@ func (c *InterplexCache) Load(key string) (string, error) {
Key: key,
}
resp, err := c.cacheServiceClient.Get(context.Background(), &req)
// check if response is cache error not found
if err != nil {
return "", err
}
return resp.Value, nil
}
func (InterplexCache) List() ([]CacheObjectDetails, error) {
//TODO implement me
return nil, errors.New("not implemented")
func (c *InterplexCache) List() ([]CacheObjectDetails, error) {
// Not implemented for Interplex cache
return []CacheObjectDetails{}, nil
}
func (InterplexCache) Remove(key string) error {
func (c *InterplexCache) Remove(key string) error {
if os.Getenv("INTERPLEX_LOCAL_MODE") != "" {
c.configuration.ConnectionString = "localhost:8084"
}
return errors.New("not implemented")
conn, err := grpc.NewClient(c.configuration.ConnectionString, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return err
}
defer func() {
if err := conn.Close(); err != nil {
// Log the error but don't return it since this is a deferred function
fmt.Printf("Error closing connection: %v\n", err)
}
}()
serviceClient := rpc.NewCacheServiceClient(conn)
c.cacheServiceClient = serviceClient
req := schemav1.DeleteRequest{
Key: key,
}
_, err = c.cacheServiceClient.Delete(context.Background(), &req)
return err
}
func (c *InterplexCache) Exists(key string) bool {
if _, err := c.Load(key); err != nil {
return false
}
return true
_, err := c.Load(key)
return err == nil
}
func (c *InterplexCache) IsCacheDisabled() bool {
return c.noCache
}
func (InterplexCache) GetName() string {
//TODO implement me
func (c *InterplexCache) GetName() string {
return "interplex"
}

View File

@@ -1,30 +1,49 @@
# serve
# K8sGPT MCP Server
The serve commands allow you to run k8sgpt in a grpc server mode.
This would be enabled typically through `k8sgpt serve` and is how the in-cluster k8sgpt deployment functions when managed by the [k8sgpt-operator](https://github.com/k8sgpt-ai/k8sgpt-operator)
This directory contains the implementation of the Mission Control Protocol (MCP) server for K8sGPT. The MCP server allows K8sGPT to be integrated with other tools that support the MCP protocol.
The grpc interface that is served is hosted on [buf](https://buf.build/k8sgpt-ai/schemas) and the repository for this is [here](https://github.com/k8sgpt-ai/schemas)
## Components
## grpcurl
- `mcp.go`: The main MCP server implementation
- `server.go`: The HTTP server implementation
- `tools.go`: Tool definitions for the MCP server
A fantastic tool for local debugging and development is `grpcurl`
It allows you to form curl like requests that are http2
e.g.
## Features
```
grpcurl -plaintext -d '{"namespace": "k8sgpt", "explain" : "true"}' localhost:8080 schema.v1.ServiceAnalyzeService/Analyze
```
The MCP server provides the following features:
```
grpcurl -plaintext localhost:8080 schema.v1.ServiceConfigService/ListIntegrations
{
"integrations": [
"prometheus"
]
1. **Analyze Kubernetes Resources**: Analyze Kubernetes resources in a cluster
2. **Get Cluster Information**: Retrieve information about the Kubernetes cluster
## Usage
To use the MCP server, you need to:
1. Initialize the MCP server with a Kubernetes client
2. Start the server
3. Connect to the server using an MCP client
Example:
```go
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Failed to create Kubernetes client: %v", err)
}
mcpServer := server.NewMCPServer(client)
if err := mcpServer.Start(); err != nil {
log.Fatalf("Failed to start MCP server: %v", err)
}
```
```
grpcurl -plaintext -d '{"integrations":{"prometheus":{"enabled":"true","namespace":"default","skipInstall":"false"}}}' localhost:8080 schema.v1.ServiceConfigService/AddConfig
```
## Integration
The MCP server can be integrated with other tools that support the MCP protocol, such as:
- Mission Control
- Other MCP-compatible tools
## License
This code is licensed under the Apache License 2.0.

View File

@@ -0,0 +1,60 @@
# K8sGPT MCP Client Example
This directory contains an example of how to use the K8sGPT MCP client in a real-world scenario.
## Prerequisites
- Go 1.16 or later
- Access to a Kubernetes cluster
- `kubectl` configured to access your cluster
## Building the Example
To build the example, run:
```bash
go build -o mcp-client-example
```
## Running the Example
To run the example, use the following command:
```bash
./mcp-client-example --kubeconfig=/path/to/kubeconfig --namespace=default
```
### Command-line Flags
- `--kubeconfig`: Path to the kubeconfig file (optional, defaults to the standard location)
- `--namespace`: Kubernetes namespace to analyze (optional)
## Example Output
When you run the example, you should see output similar to the following:
```
Starting MCP client...
```
The client will continue running until you press Ctrl+C to stop it.
## Integration with Mission Control
To integrate this example with Mission Control, you need to:
1. Start the MCP client using the example
2. Configure Mission Control to connect to the MCP client
3. Use Mission Control to analyze your Kubernetes cluster
## Troubleshooting
If you encounter any issues, check the following:
1. Ensure that your Kubernetes cluster is accessible
2. Verify that your kubeconfig file is valid
3. Check that the namespace you specified exists
## License
This code is licensed under the Apache License 2.0.

View File

@@ -0,0 +1,114 @@
/*
Copyright 2024 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"time"
)
// AnalyzeRequest represents the input parameters for the analyze tool
type AnalyzeRequest struct {
Namespace string `json:"namespace,omitempty"`
Backend string `json:"backend,omitempty"`
Language string `json:"language,omitempty"`
Filters []string `json:"filters,omitempty"`
LabelSelector string `json:"labelSelector,omitempty"`
NoCache bool `json:"noCache,omitempty"`
Explain bool `json:"explain,omitempty"`
MaxConcurrency int `json:"maxConcurrency,omitempty"`
WithDoc bool `json:"withDoc,omitempty"`
InteractiveMode bool `json:"interactiveMode,omitempty"`
CustomHeaders []string `json:"customHeaders,omitempty"`
WithStats bool `json:"withStats,omitempty"`
}
// AnalyzeResponse represents the output of the analyze tool
type AnalyzeResponse struct {
Content []struct {
Text string `json:"text"`
Type string `json:"type"`
} `json:"content"`
}
func main() {
// Parse command line flags
serverPort := flag.String("port", "8089", "Port of the MCP server")
namespace := flag.String("namespace", "", "Kubernetes namespace to analyze")
backend := flag.String("backend", "", "AI backend to use")
language := flag.String("language", "english", "Language for analysis")
flag.Parse()
// Create analyze request
req := AnalyzeRequest{
Namespace: *namespace,
Backend: *backend,
Language: *language,
Explain: true,
MaxConcurrency: 10,
}
// Convert request to JSON
reqJSON, err := json.Marshal(req)
if err != nil {
log.Fatalf("Failed to marshal request: %v", err)
}
// Create HTTP client with timeout
client := &http.Client{
Timeout: 5 * time.Minute,
}
// Send request to MCP server
resp, err := client.Post(
fmt.Sprintf("http://localhost:%s/mcp/analyze", *serverPort),
"application/json",
bytes.NewBuffer(reqJSON),
)
if err != nil {
log.Fatalf("Failed to send request: %v", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Printf("Error closing response body: %v", err)
}
}()
// Read and print raw response for debugging
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalf("Failed to read response body: %v", err)
}
fmt.Printf("Raw response: %s\n", string(body))
// Parse response
var analyzeResp AnalyzeResponse
if err := json.Unmarshal(body, &analyzeResp); err != nil {
log.Fatalf("Failed to decode response: %v", err)
}
// Print results
fmt.Println("Analysis Results:")
if len(analyzeResp.Content) > 0 {
fmt.Println(analyzeResp.Content[0].Text)
} else {
fmt.Println("No results returned")
}
}

View File

@@ -1,8 +1,9 @@
package config
import (
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
"context"
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
"github.com/k8sgpt-ai/k8sgpt/pkg/cache"
"github.com/k8sgpt-ai/k8sgpt/pkg/custom"
"github.com/spf13/viper"
@@ -20,19 +21,13 @@ const (
notUsedInsecure = false
)
func (h *Handler) AddConfig(ctx context.Context, i *schemav1.AddConfigRequest) (*schemav1.AddConfigResponse, error,
) {
resp, err := h.syncIntegration(ctx, i)
if err != nil {
return resp, err
}
// ApplyConfig applies the configuration changes from the request
func (h *Handler) ApplyConfig(ctx context.Context, i *schemav1.AddConfigRequest) error {
if i.CustomAnalyzers != nil {
// We need to add the custom analyzers to the viper config and save them
var customAnalyzers = make([]custom.CustomAnalyzer, 0)
if err := viper.UnmarshalKey("custom_analyzers", &customAnalyzers); err != nil {
return resp, err
return err
} else {
// If there are analyzers are already in the config we will append the ones with new names
for _, ca := range i.CustomAnalyzers {
@@ -56,7 +51,7 @@ func (h *Handler) AddConfig(ctx context.Context, i *schemav1.AddConfigRequest) (
// save the config
viper.Set("custom_analyzers", customAnalyzers)
if err := viper.WriteConfig(); err != nil {
return resp, err
return err
}
}
}
@@ -74,18 +69,30 @@ func (h *Handler) AddConfig(ctx context.Context, i *schemav1.AddConfigRequest) (
case *schemav1.Cache_InterplexCache:
remoteCache, err = cache.NewCacheProvider("interplex", notUsedBucket, notUsedRegion, i.Cache.GetInterplexCache().Endpoint, notUsedStorageAcc, notUsedContainerName, notUsedProjectId, notUsedInsecure)
default:
return resp, status.Error(codes.InvalidArgument, "Invalid cache configuration")
return status.Error(codes.InvalidArgument, "Invalid cache configuration")
}
if err != nil {
return resp, err
return err
}
err = cache.AddRemoteCache(remoteCache)
if err != nil {
return resp, err
return err
}
}
return nil
}
func (h *Handler) AddConfig(ctx context.Context, i *schemav1.AddConfigRequest) (*schemav1.AddConfigResponse, error) {
resp, err := h.syncIntegration(ctx, i)
if err != nil {
return resp, err
}
if err := h.ApplyConfig(ctx, i); err != nil {
return resp, err
}
return resp, nil
}

View File

@@ -1,18 +1,15 @@
package config
import (
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
"context"
"fmt"
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
"github.com/k8sgpt-ai/k8sgpt/pkg/integration"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
//const (
// trivyName = "trivy"
//)
// syncIntegration is aware of the following events
// A new integration added
// An integration removed from the Integration block

View File

@@ -0,0 +1,74 @@
/*
Copyright 2024 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"flag"
"log"
"os"
"os/signal"
"syscall"
"github.com/k8sgpt-ai/k8sgpt/pkg/ai"
"github.com/k8sgpt-ai/k8sgpt/pkg/server"
"go.uber.org/zap"
)
func main() {
// Parse command line flags
port := flag.String("port", "8089", "Port to run the MCP server on")
useHTTP := flag.Bool("http", false, "Enable HTTP mode for MCP server")
flag.Parse()
// Initialize zap logger
logger, err := zap.NewProduction()
if err != nil {
log.Fatalf("Error creating logger: %v", err)
}
defer func() {
if err := logger.Sync(); err != nil {
log.Printf("Error syncing logger: %v", err)
}
}()
// Create AI provider
aiProvider := &ai.AIProvider{
Name: "openai",
Password: os.Getenv("OPENAI_API_KEY"),
Model: "gpt-3.5-turbo",
}
// Create and start MCP server
mcpServer, err := server.NewMCPServer(*port, aiProvider, *useHTTP, logger)
if err != nil {
log.Fatalf("Error creating MCP server: %v", err)
}
// Start the server in a goroutine
go func() {
if err := mcpServer.Start(); err != nil {
log.Fatalf("Error starting MCP server: %v", err)
}
}()
// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
// Cleanup
if err := mcpServer.Close(); err != nil {
log.Printf("Error closing MCP server: %v", err)
}
}

416
pkg/server/mcp.go Normal file
View File

@@ -0,0 +1,416 @@
/*
Copyright 2024 The K8sGPT Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
"github.com/k8sgpt-ai/k8sgpt/pkg/ai"
"github.com/k8sgpt-ai/k8sgpt/pkg/analysis"
"github.com/k8sgpt-ai/k8sgpt/pkg/kubernetes"
"github.com/k8sgpt-ai/k8sgpt/pkg/server/config"
mcp_golang "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/stdio"
"github.com/spf13/viper"
"go.uber.org/zap"
)
// MCPServer represents an MCP server for k8sgpt
type MCPServer struct {
server *mcp_golang.Server
port string
aiProvider *ai.AIProvider
useHTTP bool
logger *zap.Logger
}
// NewMCPServer creates a new MCP server
func NewMCPServer(port string, aiProvider *ai.AIProvider, useHTTP bool, logger *zap.Logger) (*MCPServer, error) {
// Create MCP server with stdio transport
transport := stdio.NewStdioServerTransport()
server := mcp_golang.NewServer(transport)
return &MCPServer{
server: server,
port: port,
aiProvider: aiProvider,
useHTTP: useHTTP,
logger: logger,
}, nil
}
// Start starts the MCP server
func (s *MCPServer) Start() error {
if s.server == nil {
return fmt.Errorf("server not initialized")
}
// Register analyze tool
if err := s.server.RegisterTool("analyze", "Analyze Kubernetes resources", s.handleAnalyze); err != nil {
return fmt.Errorf("failed to register analyze tool: %v", err)
}
// Register cluster info tool
if err := s.server.RegisterTool("cluster-info", "Get Kubernetes cluster information", s.handleClusterInfo); err != nil {
return fmt.Errorf("failed to register cluster-info tool: %v", err)
}
// Register config tool
if err := s.server.RegisterTool("config", "Configure K8sGPT settings", s.handleConfig); err != nil {
return fmt.Errorf("failed to register config tool: %v", err)
}
// Register resources
if err := s.registerResources(); err != nil {
return fmt.Errorf("failed to register resources: %v", err)
}
// Register prompts
if err := s.registerPrompts(); err != nil {
return fmt.Errorf("failed to register prompts: %v", err)
}
if s.useHTTP {
// Start HTTP server
go func() {
http.HandleFunc("/mcp/analyze", s.handleAnalyzeHTTP)
http.HandleFunc("/mcp", s.handleSSE)
s.logger.Info("Starting MCP server on port", zap.String("port", s.port))
if err := http.ListenAndServe(fmt.Sprintf(":%s", s.port), nil); err != nil {
s.logger.Error("Error starting HTTP server", zap.Error(err))
}
}()
}
// Start the server
return s.server.Serve()
}
// AnalyzeRequest represents the input parameters for the analyze tool
type AnalyzeRequest struct {
Namespace string `json:"namespace,omitempty"`
Backend string `json:"backend,omitempty"`
Language string `json:"language,omitempty"`
Filters []string `json:"filters,omitempty"`
LabelSelector string `json:"labelSelector,omitempty"`
NoCache bool `json:"noCache,omitempty"`
Explain bool `json:"explain,omitempty"`
MaxConcurrency int `json:"maxConcurrency,omitempty"`
WithDoc bool `json:"withDoc,omitempty"`
InteractiveMode bool `json:"interactiveMode,omitempty"`
CustomHeaders []string `json:"customHeaders,omitempty"`
WithStats bool `json:"withStats,omitempty"`
}
// AnalyzeResponse represents the output of the analyze tool
type AnalyzeResponse struct {
Results string `json:"results"`
}
// ClusterInfoRequest represents the input parameters for the cluster-info tool
type ClusterInfoRequest struct {
// Empty struct as we don't need any input parameters
}
// ClusterInfoResponse represents the output of the cluster-info tool
type ClusterInfoResponse struct {
Info string `json:"info"`
}
// ConfigRequest represents the input parameters for the config tool
type ConfigRequest struct {
CustomAnalyzers []struct {
Name string `json:"name"`
Connection struct {
Url string `json:"url"`
Port int `json:"port"`
} `json:"connection"`
} `json:"customAnalyzers,omitempty"`
Cache struct {
Type string `json:"type"`
// S3 specific fields
BucketName string `json:"bucketName,omitempty"`
Region string `json:"region,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Insecure bool `json:"insecure,omitempty"`
// Azure specific fields
StorageAccount string `json:"storageAccount,omitempty"`
ContainerName string `json:"containerName,omitempty"`
// GCS specific fields
ProjectId string `json:"projectId,omitempty"`
} `json:"cache,omitempty"`
}
// ConfigResponse represents the output of the config tool
type ConfigResponse struct {
Status string `json:"status"`
}
// handleAnalyze handles the analyze tool
func (s *MCPServer) handleAnalyze(ctx context.Context, request *AnalyzeRequest) (*mcp_golang.ToolResponse, error) {
// Get stored configuration
var configAI ai.AIConfiguration
if err := viper.UnmarshalKey("ai", &configAI); err != nil {
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(fmt.Sprintf("Failed to load AI configuration: %v", err))), nil
}
// Use stored configuration if not specified in request
if request.Backend == "" {
if configAI.DefaultProvider != "" {
request.Backend = configAI.DefaultProvider
} else if len(configAI.Providers) > 0 {
request.Backend = configAI.Providers[0].Name
} else {
request.Backend = "openai" // fallback default
}
}
request.Explain = true
// Get stored filters if not specified
if len(request.Filters) == 0 {
request.Filters = viper.GetStringSlice("active_filters")
}
// Validate MaxConcurrency to prevent excessive memory allocation
request.MaxConcurrency = validateMaxConcurrency(request.MaxConcurrency)
// Create a new analysis with the request parameters
analysis, err := analysis.NewAnalysis(
request.Backend,
request.Language,
request.Filters,
request.Namespace,
request.LabelSelector,
request.NoCache,
request.Explain,
request.MaxConcurrency,
request.WithDoc,
request.InteractiveMode,
request.CustomHeaders,
request.WithStats,
)
if err != nil {
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(fmt.Sprintf("Failed to create analysis: %v", err))), nil
}
defer analysis.Close()
// Run the analysis
analysis.RunAnalysis()
// Get the output
output, err := analysis.PrintOutput("json")
if err != nil {
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(fmt.Sprintf("Failed to print output: %v", err))), nil
}
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(string(output))), nil
}
// validateMaxConcurrency validates and bounds the MaxConcurrency parameter
func validateMaxConcurrency(maxConcurrency int) int {
const maxAllowedConcurrency = 100
if maxConcurrency <= 0 {
return 10 // Default value if not set
} else if maxConcurrency > maxAllowedConcurrency {
return maxAllowedConcurrency // Cap at a reasonable maximum
}
return maxConcurrency
}
// handleClusterInfo handles the cluster-info tool
func (s *MCPServer) handleClusterInfo(ctx context.Context, request *ClusterInfoRequest) (*mcp_golang.ToolResponse, error) {
// Create a new Kubernetes client
client, err := kubernetes.NewClient("", "")
if err != nil {
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(fmt.Sprintf("failed to create Kubernetes client: %v", err))), nil
}
// Get cluster info from the client
version, err := client.Client.Discovery().ServerVersion()
if err != nil {
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(fmt.Sprintf("failed to get cluster version: %v", err))), nil
}
info := fmt.Sprintf("Kubernetes %s", version.GitVersion)
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(info)), nil
}
// handleConfig handles the config tool
func (s *MCPServer) handleConfig(ctx context.Context, request *ConfigRequest) (*mcp_golang.ToolResponse, error) {
// Create a new config handler
handler := &config.Handler{}
// Convert request to AddConfigRequest
addConfigReq := &schemav1.AddConfigRequest{
CustomAnalyzers: make([]*schemav1.CustomAnalyzer, 0),
}
// Add custom analyzers if present
if len(request.CustomAnalyzers) > 0 {
for _, ca := range request.CustomAnalyzers {
addConfigReq.CustomAnalyzers = append(addConfigReq.CustomAnalyzers, &schemav1.CustomAnalyzer{
Name: ca.Name,
Connection: &schemav1.Connection{
Url: ca.Connection.Url,
Port: fmt.Sprintf("%d", ca.Connection.Port),
},
})
}
}
// Add cache configuration if present
if request.Cache.Type != "" {
cacheConfig := &schemav1.Cache{}
switch request.Cache.Type {
case "s3":
cacheConfig.CacheType = &schemav1.Cache_S3Cache{
S3Cache: &schemav1.S3Cache{
BucketName: request.Cache.BucketName,
Region: request.Cache.Region,
Endpoint: request.Cache.Endpoint,
Insecure: request.Cache.Insecure,
},
}
case "azure":
cacheConfig.CacheType = &schemav1.Cache_AzureCache{
AzureCache: &schemav1.AzureCache{
StorageAccount: request.Cache.StorageAccount,
ContainerName: request.Cache.ContainerName,
},
}
case "gcs":
cacheConfig.CacheType = &schemav1.Cache_GcsCache{
GcsCache: &schemav1.GCSCache{
BucketName: request.Cache.BucketName,
Region: request.Cache.Region,
ProjectId: request.Cache.ProjectId,
},
}
}
addConfigReq.Cache = cacheConfig
}
// Apply the configuration using the shared function
if err := handler.ApplyConfig(ctx, addConfigReq); err != nil {
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(fmt.Sprintf("Failed to add config: %v", err))), nil
}
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent("Successfully added configuration")), nil
}
// registerPrompts registers the prompts for the MCP server
func (s *MCPServer) registerPrompts() error {
// Register any prompts needed for the MCP server
return nil
}
// registerResources registers the resources for the MCP server
func (s *MCPServer) registerResources() error {
if err := s.server.RegisterResource("cluster-info", "Get cluster information", "Get information about the Kubernetes cluster", "text", s.getClusterInfo); err != nil {
return fmt.Errorf("failed to register cluster-info resource: %v", err)
}
return nil
}
func (s *MCPServer) getClusterInfo(ctx context.Context) (interface{}, error) {
// Create a new Kubernetes client
client, err := kubernetes.NewClient("", "")
if err != nil {
return nil, fmt.Errorf("failed to create Kubernetes client: %v", err)
}
// Get cluster info from the client
version, err := client.Client.Discovery().ServerVersion()
if err != nil {
return nil, fmt.Errorf("failed to get cluster version: %v", err)
}
return map[string]string{
"version": version.String(),
"platform": version.Platform,
"gitVersion": version.GitVersion,
}, nil
}
// handleSSE handles Server-Sent Events for MCP
func (s *MCPServer) handleSSE(w http.ResponseWriter, r *http.Request) {
// Set headers for SSE
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Create a channel to receive messages
msgChan := make(chan string)
defer close(msgChan)
// Start a goroutine to handle the stdio transport
go func() {
// TODO: Implement message handling between HTTP and stdio transport
// This would require implementing a custom transport that bridges HTTP and stdio
}()
// Send messages to the client
for msg := range msgChan {
if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil {
s.logger.Error("Failed to write SSE message", zap.Error(err))
return
}
w.(http.Flusher).Flush()
}
}
// handleAnalyzeHTTP handles HTTP requests for the analyze endpoint
func (s *MCPServer) handleAnalyzeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse the request body
var req AnalyzeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("Failed to decode request: %v", err), http.StatusBadRequest)
return
}
// Validate MaxConcurrency to prevent excessive memory allocation
req.MaxConcurrency = validateMaxConcurrency(req.MaxConcurrency)
// Call the analyze handler
resp, err := s.handleAnalyze(r.Context(), &req)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to analyze: %v", err), http.StatusInternalServerError)
return
}
// Set response headers
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Write the response
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.logger.Error("Failed to encode response", zap.Error(err))
}
}
// Close closes the MCP server and releases resources
func (s *MCPServer) Close() error {
return nil
}

View File

@@ -1,8 +1,10 @@
package query
import (
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
"context"
"fmt"
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
"github.com/k8sgpt-ai/k8sgpt/pkg/ai"
)
@@ -10,9 +12,50 @@ func (h *Handler) Query(ctx context.Context, i *schemav1.QueryRequest) (
*schemav1.QueryResponse,
error,
) {
aiClient := ai.NewClient(i.Backend)
// Create client factory and config provider
factory := ai.GetAIClientFactory()
configProvider := ai.GetConfigProvider()
// Use the factory to create the client
aiClient := factory.NewClient(i.Backend)
defer aiClient.Close()
var configAI ai.AIConfiguration
if err := configProvider.UnmarshalKey("ai", &configAI); err != nil {
return &schemav1.QueryResponse{
Response: "",
Error: &schemav1.QueryError{
Message: fmt.Sprintf("Failed to unmarshal AI configuration: %v", err),
},
}, nil
}
var aiProvider ai.AIProvider
for _, provider := range configAI.Providers {
if i.Backend == provider.Name {
aiProvider = provider
break
}
}
if aiProvider.Name == "" {
return &schemav1.QueryResponse{
Response: "",
Error: &schemav1.QueryError{
Message: fmt.Sprintf("AI provider %s not found in configuration", i.Backend),
},
}, nil
}
// Configure the AI client
if err := aiClient.Configure(&aiProvider); err != nil {
return &schemav1.QueryResponse{
Response: "",
Error: &schemav1.QueryError{
Message: fmt.Sprintf("Failed to configure AI client: %v", err),
},
}, nil
}
resp, err := aiClient.GetCompletion(ctx, i.Query)
var errMessage string = ""
if err != nil {

View File

@@ -0,0 +1,310 @@
package query
import (
"context"
"errors"
"testing"
schemav1 "buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go/schema/v1"
"github.com/k8sgpt-ai/k8sgpt/pkg/ai"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// MockAI is a mock implementation of the ai.IAI interface for testing
type MockAI struct {
mock.Mock
}
func (m *MockAI) Configure(config ai.IAIConfig) error {
args := m.Called(config)
return args.Error(0)
}
func (m *MockAI) GetCompletion(ctx context.Context, prompt string) (string, error) {
args := m.Called(ctx, prompt)
return args.String(0), args.Error(1)
}
func (m *MockAI) GetName() string {
args := m.Called()
return args.String(0)
}
func (m *MockAI) Close() {
m.Called()
}
// MockAIClientFactory is a mock implementation of AIClientFactory
type MockAIClientFactory struct {
mock.Mock
}
func (m *MockAIClientFactory) NewClient(provider string) ai.IAI {
args := m.Called(provider)
return args.Get(0).(ai.IAI)
}
// MockConfigProvider is a mock implementation of ConfigProvider
type MockConfigProvider struct {
mock.Mock
}
func (m *MockConfigProvider) UnmarshalKey(key string, rawVal interface{}) error {
args := m.Called(key, rawVal)
// If we want to set the rawVal (which is a pointer)
if fn, ok := args.Get(0).(func(interface{})); ok && fn != nil {
fn(rawVal)
}
// Return the error as the first return value
return args.Error(0)
}
func TestQuery_Success(t *testing.T) {
// Setup mocks
mockAI := new(MockAI)
mockFactory := new(MockAIClientFactory)
mockConfig := new(MockConfigProvider)
// Set test implementations
ai.SetTestAIClientFactory(mockFactory)
ai.SetTestConfigProvider(mockConfig)
defer ai.ResetTestImplementations()
// Define test data
testBackend := "test-backend"
testQuery := "test query"
testResponse := "test response"
// Setup expectations
mockFactory.On("NewClient", testBackend).Return(mockAI)
mockAI.On("Close").Return()
// Set up configuration with a valid provider
mockConfig.On("UnmarshalKey", "ai", mock.Anything).Run(func(args mock.Arguments) {
config := args.Get(1).(*ai.AIConfiguration)
*config = ai.AIConfiguration{
Providers: []ai.AIProvider{
{
Name: testBackend,
Password: "test-password",
Model: "test-model",
},
},
}
}).Return(nil)
mockAI.On("Configure", mock.AnythingOfType("*ai.AIProvider")).Return(nil)
mockAI.On("GetCompletion", mock.Anything, testQuery).Return(testResponse, nil)
// Create handler and call Query
handler := &Handler{}
response, err := handler.Query(context.Background(), &schemav1.QueryRequest{
Backend: testBackend,
Query: testQuery,
})
// Assertions
assert.NoError(t, err)
assert.NotNil(t, response)
assert.Equal(t, testResponse, response.Response)
assert.Equal(t, "", response.Error.Message)
// Verify mocks
mockAI.AssertExpectations(t)
mockFactory.AssertExpectations(t)
mockConfig.AssertExpectations(t)
}
func TestQuery_UnmarshalError(t *testing.T) {
// Setup mocks
mockAI := new(MockAI)
mockFactory := new(MockAIClientFactory)
mockConfig := new(MockConfigProvider)
// Set test implementations
ai.SetTestAIClientFactory(mockFactory)
ai.SetTestConfigProvider(mockConfig)
defer ai.ResetTestImplementations()
// Setup expectations
mockFactory.On("NewClient", "test-backend").Return(mockAI)
mockAI.On("Close").Return()
// Mock unmarshal error
mockConfig.On("UnmarshalKey", "ai", mock.Anything).Return(errors.New("unmarshal error"))
// Create handler and call Query
handler := &Handler{}
response, err := handler.Query(context.Background(), &schemav1.QueryRequest{
Backend: "test-backend",
Query: "test query",
})
// Assertions
assert.NoError(t, err)
assert.NotNil(t, response)
assert.Equal(t, "", response.Response)
assert.Contains(t, response.Error.Message, "Failed to unmarshal AI configuration")
// Verify mocks
mockAI.AssertExpectations(t)
mockFactory.AssertExpectations(t)
mockConfig.AssertExpectations(t)
}
func TestQuery_ProviderNotFound(t *testing.T) {
// Setup mocks
mockAI := new(MockAI)
mockFactory := new(MockAIClientFactory)
mockConfig := new(MockConfigProvider)
// Set test implementations
ai.SetTestAIClientFactory(mockFactory)
ai.SetTestConfigProvider(mockConfig)
defer ai.ResetTestImplementations()
// Define test data
testBackend := "test-backend"
// Setup expectations
mockFactory.On("NewClient", testBackend).Return(mockAI)
mockAI.On("Close").Return()
// Set up configuration with no matching provider
mockConfig.On("UnmarshalKey", "ai", mock.Anything).Run(func(args mock.Arguments) {
config := args.Get(1).(*ai.AIConfiguration)
*config = ai.AIConfiguration{
Providers: []ai.AIProvider{
{
Name: "other-backend",
},
},
}
}).Return(nil)
// Create handler and call Query
handler := &Handler{}
response, err := handler.Query(context.Background(), &schemav1.QueryRequest{
Backend: testBackend,
Query: "test query",
})
// Assertions
assert.NoError(t, err)
assert.NotNil(t, response)
assert.Equal(t, "", response.Response)
assert.Contains(t, response.Error.Message, "AI provider test-backend not found in configuration")
// Verify mocks
mockAI.AssertExpectations(t)
mockFactory.AssertExpectations(t)
mockConfig.AssertExpectations(t)
}
func TestQuery_ConfigureError(t *testing.T) {
// Setup mocks
mockAI := new(MockAI)
mockFactory := new(MockAIClientFactory)
mockConfig := new(MockConfigProvider)
// Set test implementations
ai.SetTestAIClientFactory(mockFactory)
ai.SetTestConfigProvider(mockConfig)
defer ai.ResetTestImplementations()
// Define test data
testBackend := "test-backend"
// Setup expectations
mockFactory.On("NewClient", testBackend).Return(mockAI)
mockAI.On("Close").Return()
// Set up configuration with a valid provider
mockConfig.On("UnmarshalKey", "ai", mock.Anything).Run(func(args mock.Arguments) {
config := args.Get(1).(*ai.AIConfiguration)
*config = ai.AIConfiguration{
Providers: []ai.AIProvider{
{
Name: testBackend,
},
},
}
}).Return(nil)
// Mock configure error
mockAI.On("Configure", mock.AnythingOfType("*ai.AIProvider")).Return(errors.New("configure error"))
// Create handler and call Query
handler := &Handler{}
response, err := handler.Query(context.Background(), &schemav1.QueryRequest{
Backend: testBackend,
Query: "test query",
})
// Assertions
assert.NoError(t, err)
assert.NotNil(t, response)
assert.Equal(t, "", response.Response)
assert.Contains(t, response.Error.Message, "Failed to configure AI client")
// Verify mocks
mockAI.AssertExpectations(t)
mockFactory.AssertExpectations(t)
mockConfig.AssertExpectations(t)
}
func TestQuery_GetCompletionError(t *testing.T) {
// Setup mocks
mockAI := new(MockAI)
mockFactory := new(MockAIClientFactory)
mockConfig := new(MockConfigProvider)
// Set test implementations
ai.SetTestAIClientFactory(mockFactory)
ai.SetTestConfigProvider(mockConfig)
defer ai.ResetTestImplementations()
// Define test data
testBackend := "test-backend"
testQuery := "test query"
// Setup expectations
mockFactory.On("NewClient", testBackend).Return(mockAI)
mockAI.On("Close").Return()
// Set up configuration with a valid provider
mockConfig.On("UnmarshalKey", "ai", mock.Anything).Run(func(args mock.Arguments) {
config := args.Get(1).(*ai.AIConfiguration)
*config = ai.AIConfiguration{
Providers: []ai.AIProvider{
{
Name: testBackend,
},
},
}
}).Return(nil)
mockAI.On("Configure", mock.AnythingOfType("*ai.AIProvider")).Return(nil)
mockAI.On("GetCompletion", mock.Anything, testQuery).Return("", errors.New("completion error"))
// Create handler and call Query
handler := &Handler{}
response, err := handler.Query(context.Background(), &schemav1.QueryRequest{
Backend: testBackend,
Query: testQuery,
})
// Assertions
assert.NoError(t, err)
assert.NotNil(t, response)
assert.Equal(t, "", response.Response)
assert.Equal(t, "completion error", response.Error.Message)
// Verify mocks
mockAI.AssertExpectations(t)
mockFactory.AssertExpectations(t)
mockConfig.AssertExpectations(t)
}

View File

@@ -14,6 +14,7 @@ limitations under the License.
package util
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
@@ -311,3 +312,33 @@ func LabelStrToSelector(labelStr string) labels.Selector {
}
return labels.SelectorFromSet(labels.Set(labelSelectorMap))
}
// CaptureOutput captures the output of a function that writes to stdout
func CaptureOutput(f func()) string {
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
panic(fmt.Sprintf("failed to create pipe: %v", err))
}
os.Stdout = w
// Ensure os.Stdout is restored even if panic occurs
defer func() {
os.Stdout = old
}()
f()
if err := w.Close(); err != nil {
panic(fmt.Sprintf("failed to close writer: %v", err))
}
var buf bytes.Buffer
if _, err := buf.ReadFrom(r); err != nil {
panic(fmt.Sprintf("failed to read from pipe: %v", err))
}
return buf.String()
}
// Contains checks if substr is present in s
func Contains(s, substr string) bool {
return bytes.Contains([]byte(s), []byte(substr))
}