44 Commits

Author SHA1 Message Date
Andrei Pohilko
8faa5dabd5 New features of v1.0 2023-02-01 16:01:35 +00:00
Andrei Pohilko
0b891e7c28 Banner with survey link 2023-02-01 15:38:20 +00:00
Andrei Pohilko
2736777b8a Mod tidy 2023-02-01 13:41:39 +00:00
dependabot[bot]
96c789a012 Bump github.com/containerd/containerd from 1.6.6 to 1.6.12 (#207)
Bumps [github.com/containerd/containerd](https://github.com/containerd/containerd) from 1.6.6 to 1.6.12.
- [Release notes](https://github.com/containerd/containerd/releases)
- [Changelog](https://github.com/containerd/containerd/blob/main/RELEASES.md)
- [Commits](https://github.com/containerd/containerd/compare/v1.6.6...v1.6.12)

---
updated-dependencies:
- dependency-name: github.com/containerd/containerd
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-01 13:39:55 +00:00
Andrey Pokhilko
e13aa2fde6 [WIP] Major release 1.0 (#147)
* Object model with self-sufficient binary (#131)

* Code cosmetics

* Experimenting with object model and direct HELM usage

* Experiment with object model

* replacing the kubectl

* Progressing

* Save the progress

* Able to start with migration in mind

* Migrated two pieces

* List releases via Helm

* Forgotten field

* Cristallized the problem of ctx switcher

* Reworked to multi-context

* Rollback is also new style

* More migration

* Refactoring

* Describe via code

* Bye-bye kubectl binary

* Eliminate more old code

* Refactor a bit

* Merges

* No binaries in dockerfile

* Commit

* Progress with getting the data

* Learned the thing about get

* One field less

* Sstart with repos

* Repo add

* repo remove

* Repos! Icons!

* Simplified access to data

* Ver listing works

* Ver check works

* Caching and values

* fixup

* Done with repos

* Working on install

* Install work-ish

* Fix UI failing on install

* Upgrade flow works

* Fix image building

* Remove outdated test file

* Move files around

* REfactorings

* Cosmetics

* Test for cache control (#151)

* Files import formatted

* Added go-test tools

* Added test for no-cache header

* added changes

* test for cache behaviour of app

* test for static route (#153)

* Tests: route configuration & context setter (#154)

* Test for route configuration

* Test for context setter middleware

* implemented changes

* Restore coverage profile

Fixes #156

* Cosmetics

* Test for `NewRouter` function (#157)

* Test for `configureScanners` (#158)

* Test for `configureKubectls` (#163)

* Test for repository loading (#169)

- Created `repos_test.go`
- Test: `Load()` of Repositories

* Build all PRs

* Fixes failing test (#171)

* Fixes failing test
- Fixes failing test of repo loading

* handles error for

* Did some changes

* Test for listing of repos (#173)

- and did some code formatting

Signed-off-by: OmAxiani0 <aximaniom@gmail.com>

Signed-off-by: OmAxiani0 <aximaniom@gmail.com>

* Test for adding repo (#175)

- Modified the `repositories.yml` file

Signed-off-by: OmAxiani0 <aximaniom@gmail.com>

Signed-off-by: OmAxiani0 <aximaniom@gmail.com>

* Test for deleting the repository (#176)

* Test for deleting the repository
- Also added cleanup function for `TestAdd`

* Fixes failing test

* Add auto labeler for PR's (#174)

* Add auto labeler for PR's

* Add all file under .github/workflow to 'ci' label

Co-authored-by: Harshit Mehta <harshitm@nvidia.com>

* Test for getting repository (#177)

* Add github workflow for auto PR labeling (#181)

Co-authored-by: Harshit Mehta <harshitm@nvidia.com>

* Stub compilation

* Fixes around installing

* More complex test

* Using object model to execute helm test (#191)

* Expand test

* More test

* Coverage

* Add mutex for operations

* Rectore cluster detection code

* Change receiver to pointer

* Support multiple namespaces

* Cosmetics

* Update repos periodically

* fix tests

* Fix error display

* Allow reconfiguring chart without repo

* mute  linter

* Cosmetics

* Failing approach to parse manifests

Relates to #30

* Report the error properly

*  Add test for dashboard/objects/data.go NewDataLayer (#199)

* Fix problem of wrong namespace

* Added unit tests for releases (#204)

* Rework API routes (#197)

* Bootstrap OpenAPI doc

* Renaming some routes

* Listing namespaces

* k8s part of things

* Repositories section

* Document scanners API

* One more API call

* Progress

* Reworked install flow

* History endpoint

* Textual info section

* Resources endpoint

* Rollback endpoint

* Rollback endpoint

* Unit tests

* Cleanup

* Forgotten tags

* Fix tests

* TODOs

* Rework manifest scanning

* add hasTests flag

* Adding more information on UI for helm test API response (#195)

* Hide test button when no tests

Fixes #115
Improves #195

---------

Signed-off-by: OmAxiani0 <aximaniom@gmail.com>
Co-authored-by: Om Aximani <75031769+OmAximani0@users.noreply.github.com>
Co-authored-by: Harshit Mehta <hdm23061993@gmail.com>
Co-authored-by: Harshit Mehta <harshitm@nvidia.com>
Co-authored-by: Todd Turner <todd@toddtee.sh>
Co-authored-by: arvindsundararajan98 <109727359+arvindsundararajan98@users.noreply.github.com>
2023-02-01 13:24:34 +00:00
Andrei Pohilko
6ffcdf2b8e Commit plugin.yaml update upon release 2023-02-01 09:58:26 +00:00
Andrei Pohilko
e6d6ff41a9 Revert "Update release workflow (#202)"
This reverts commit 2df533ab5b.
2023-02-01 09:34:44 +00:00
Dennis Ploeger
0e45e89a1e fix: Make PVC use storageClass from values (#206) 2023-01-31 15:34:13 +00:00
ronahk
2df533ab5b Update release workflow (#202)
* Update release workflow

* Update release workflow

* Change label
2023-01-26 14:12:51 +00:00
Todd Turner
7e32008bfe 🔧 Allow binary arguments via helm chart (#201)
This commit allows to pass arguments to the helm chart.
This is useful if you want to specify arguments for the
helm-dashboard binary whilst deploying via helm.
2023-01-26 14:12:16 +00:00
iiTidgex
23cfd2d61b support for deployment strategy (#196)
Co-authored-by: larmitage_wh <luke.armitage@williamhill.com>
2023-01-23 08:38:57 +00:00
Andrei Pohilko
0e6231dfbd Better debug flag logic 2023-01-20 13:32:55 +00:00
Andrei Pohilko
4ebf67095a Add note about auth proxies 2023-01-18 20:04:40 +00:00
Andrei Pohilko
5902bc010e remove undesired output from version check 2023-01-17 10:44:49 +00:00
Andrei Pohilko
36217401c8 Merge branch 'main' of github.com:komodorio/helm-dashboard 2023-01-17 10:39:30 +00:00
Andrei Pohilko
e6381f96e9 Add check for install script 2023-01-17 10:38:51 +00:00
Harshit Mehta
efe394c8c4 Provide execution rights to install script (#192) 2023-01-17 10:30:34 +00:00
Andrei Pohilko
a91287c2ff Convert line endings back 2023-01-16 20:09:42 +00:00
Andrei Pohilko
28a4b37bb5 Add namespace parameter to chart values
closes #189
2023-01-16 19:15:36 +00:00
Andrei Pohilko
2e1f2e481b Conditional debug flag for install script 2023-01-16 18:44:36 +00:00
Andrei Pohilko
714c0f9e02 Merge branch 'main' of github.com:komodorio/helm-dashboard 2023-01-16 09:58:29 +00:00
Andrei Pohilko
35421ede58 Change install_script.sh line endings to \r\n
Relates to #188
2023-01-16 09:58:08 +00:00
ronahk
26e0b1db32 Fix typo in readme 2023-01-15 13:40:06 +02:00
ronahk
50947e585d Break filters text when resizing window (#190) 2023-01-15 13:25:41 +02:00
ronahk
40ae829186 Use v3 checkout action in all places 2023-01-15 12:46:21 +02:00
ronahk
87ee388bfb Handle errors in regex match 2023-01-15 12:18:40 +02:00
Andrei Pohilko
b76fbb130a Merge branch 'main' of github.com:komodorio/helm-dashboard 2023-01-13 12:41:05 +00:00
Todd Turner
f3c66ecf03 🎨 Remove Superfluous Rule from helm-dashboard ClusterRole (#187)
A superfluous rule is added to the ClusterRole upon creation,
when the dashboard.allowWriteActions value is set to true.
This commit will ensure that only a single rule is created within
the ClusterRole, regardless of whether the dashboard.allowWriteActions
value is enabled or not.
The verbs within this rule will update accordingly.
2023-01-13 10:56:48 +00:00
Harshit Mehta
83e4348ace Add option to execute Tests for release (#178)
* Add button to execute tests

* Create API to execute tests

* Add modal for Test response

* Make API call to execute tests and show response in modal

* Clean up

* Update docs - feature execute tests for a release

* Add arg '--logs' to 'helm test' cmd

* Wait for API to complete before sending back response to frontend

* Add loading spinner until reponse for 'helm test' is returned from backend by API

* Clean-up

Co-authored-by: Harshit Mehta <harshitm@nvidia.com>
2023-01-12 12:35:11 +00:00
Harshit Mehta
af1c09ae02 Update Kommunity link (#184) 2023-01-12 11:21:04 +00:00
Udi Hofesh
6e5bce26e8 Update Kommunity link (#183)
The previous link has expired. Adding a never failing signup page: https://komodorkommunity.slack.com/
2023-01-12 09:29:21 +00:00
Harshit Mehta
d0d2ed3ef1 Add github workflow for auto PR labeling (#179)
Co-authored-by: Harshit Mehta <harshitm@nvidia.com>
2023-01-09 14:35:02 +00:00
Harshit Mehta
450190a1aa Add auto labeler for PR's (#172)
Co-authored-by: Harshit Mehta <harshitm@nvidia.com>
2023-01-06 08:26:13 +00:00
Harshit Mehta
74aab13e3e Use github api to fetch latest release version (#170)
* Use github api to fetch latest release version

* Use grep instead of jq for querying json response

Co-authored-by: Harshit Mehta <harshitm@nvidia.com>
2023-01-05 11:42:47 +00:00
Om Aximani
c5cd12b6b2 Ignore cover profile file (#165)
- Added `.cov` extension to gitignore
2023-01-02 13:29:10 +00:00
Om Aximani
fd650f10b6 Changed version of action (#160)
- fix: #159
2023-01-01 16:30:45 +00:00
ronahk
3e1e4be4b3 Update plugin.yaml to latest release version (#155) 2022-12-31 09:26:55 +00:00
Andrei Pohilko
d0c9de9718 Merge branch 'main' of github.com:komodorio/helm-dashboard 2022-12-24 12:14:51 +00:00
ronahk
1a1b28d09f Markdown additions (#150)
* beautify readme

* beautify readme

* merge

* remove file

* center icon
2022-12-24 09:42:22 +00:00
Nirav_Prajapati
6c4ee06d27 feat: added pull request template (#149) 2022-12-22 15:51:13 +02:00
Nirav_Prajapati
7c5ffe9c07 feat: Add issue template (#146)
added issue template
2022-12-22 10:01:47 +02:00
Om Aximani
196644683c Added 'debug' flag in values (#143)
* Added 'debug' flag in values

- Helps to set environment on debug mode
- Can fix #142

* Addressed changes
2022-12-19 19:08:30 +02:00
komodor-bot
20ee6e9695 Increment chart versions [skip ci] 2022-12-19 12:30:52 +00:00
Andrei Pohilko
2c25193adf Code cosmetics 2022-12-06 15:31:18 +00:00
62 changed files with 4775 additions and 1292 deletions

59
.github/ISSUE_TEMPLATE/bug.yaml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: 🐛 Bug
description: Report an issue to help improve the project.
labels: ["🛠 goal: fix"]
body:
- type: textarea
id: description
attributes:
label: Description
description: A brief description of the question or issue, also include what you tried and what didn't work
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Please add screenshots if applicable
validations:
required: false
- type: textarea
id: extrainfo
attributes:
label: Additional information
description: Is there anything else we should know about this bug?
validations:
required: false
- type: markdown
attributes:
value: |
You can also join our slack community [here](https://komodorkommunity.slack.com)
Feel free to check out other cool repositories of the [komodorio](https://github.com/komodorio)

68
.github/labeler.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
# This configures label matching for PR's.
#
# The keys are labels, and the values are lists of minimatch patterns
# to which those labels apply.
#
# NOTE: This can only add labels, not remove them.
# NOTE: Due to YAML syntax limitations, patterns or labels which start
# with a character that is part of the standard YAML syntax must be
# quoted.
#
# Please keep the labels sorted and deduplicated.
api:
- pkg/dashboard/api.go
app:
- main.go
- pkg/dashboard/server.go
- pkg/dashboard/subproc/*
- pkg/dashboard/utils/*
backend:
- pkg/dashboard/handlers/*
- pkg/dashboard/scanners/*
ci:
- .github/workflow/build.yml
- ci/*
- Makefile
- scripts/*
docs:
- CODE_OF_CONDUCT.md
- CONTRIBUTING.md
- LICENSE
- README.md
- screenshot*.png
- screenshot*.svg
docker:
- .dockerignore
- Dockerfile
helm-charts:
- charts/*
github-actions:
- .github/ISSUE_TEMPLATE/*
- .github/labeler.yml
- .github/pull_request_template
- .github/workflow/pull-request-labeler.yaml
release:
- .github/workflows/publish-chart.yaml
- .github/workflows/release.yaml
- .goreleaser.yml
- artifacthub-repo.yml
- plugin.yaml
scanners:
- pkg/dashboard/scanners/*
tests:
- pkg/dashboard/**/*_test.go
- pkg/dashboard/objects/testdata/*
frontend:
- pkg/dashboard/static/*

35
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,35 @@
<!-- If your PR fixes an open issue, use `Closes #999` to link your PR with the issue. #999 stands for the issue number you are fixing -->
## Fixes Issue
<!-- Remove this section if not applicable -->
<!-- Example: Closes #31 -->
## Changes proposed
<!-- List all the proposed changes in your PR -->
<!-- Mark all the applicable boxes. To mark the box as done follow the following conventions -->
<!--
[x] - Correct; marked as done
[X] - Correct; marked as done
[ ] - Not correct; marked as **not** done
-->
## Check List (Check all the applicable boxes) <!-- Follow the above conventions to check the box -->
- [ ] My code follows the code style of this project.
- [ ] My change requires changes to the documentation.
- [ ] I have updated the documentation accordingly.
- [ ] All new and existing tests passed.
- [ ] The title of my pull request is a short description of the requested changes.
## Screenshots
<!-- Add all the screenshots which support your changes -->
## Note to reviewers
<!-- Add notes to reviewers if applicable -->

View File

@@ -4,7 +4,7 @@ on:
push: push:
branches: main branches: main
pull_request: pull_request:
branches: main branches: "*"
jobs: jobs:
build: build:
@@ -18,7 +18,7 @@ jobs:
go-version: 1.18 go-version: 1.18
- name: Unit tests - name: Unit tests
run: | run: |
go test -v -race ./... # Run all the tests with the race detector enabled go test -v -race ./... -covermode=atomic # Run all the tests with the race detector enabled
- name: Static analysis - name: Static analysis
run: | run: |
go vet ./... # go vet is the official Go static analyzer go vet ./... # go vet is the official Go static analyzer
@@ -34,7 +34,7 @@ jobs:
- name: Test Binary is Runnable - name: Test Binary is Runnable
run: "dist/helm-dashboard_linux_amd64_v1/helm-dashboard --help" run: "dist/helm-dashboard_linux_amd64_v1/helm-dashboard --help"
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v3.2.0 uses: golangci/golangci-lint-action@v3.3.1
with: with:
# version: latest # version: latest
# skip-go-installation: true # skip-go-installation: true
@@ -47,7 +47,7 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Build and push - name: Build and push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Helm Template Check For Sanity - name: Helm Template Check For Sanity
@@ -68,3 +68,6 @@ jobs:
env: env:
CHART_LOCATION: ./charts/helm-dashboard CHART_LOCATION: ./charts/helm-dashboard
CHART_VALUES: ./charts/helm-dashboard/values.yaml CHART_VALUES: ./charts/helm-dashboard/values.yaml
- name: Test Helm plugin install script is runnable
run: |
scripts/install_plugin.sh

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Bump versions - name: Bump versions

View File

@@ -0,0 +1,14 @@
name: "Pull Request Labeler"
on:
- pull_request_target
jobs:
triage:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v4
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"

View File

@@ -18,26 +18,16 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Get plugin version
id: get_plugin_version
run: echo "PLUGIN_VERSION=$(cat plugin.yaml | grep "version" | cut -d '"' -f 2)" >> $GITHUB_OUTPUT
- name: Get tag name - name: Get tag name
id: get_tag_name id: get_tag_name
run: echo "TAG_NAME=$(echo ${{ github.ref_name }} | cut -d 'v' -f2)" >> $GITHUB_OUTPUT run: echo "TAG_NAME=$(echo ${{ github.ref_name }} | cut -d 'v' -f2)" >> $GITHUB_OUTPUT
outputs: outputs:
plugin_version: ${{ steps.get_plugin_version.outputs.PLUGIN_VERSION }}
release_tag: ${{ steps.get_tag_name.outputs.TAG_NAME }} release_tag: ${{ steps.get_tag_name.outputs.TAG_NAME }}
release: release:
needs: pre_release needs: pre_release
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Plugin version/Tag name Check
if: needs.pre_release.outputs.release_tag != needs.pre_release.outputs.plugin_version
uses: actions/github-script@v3
with:
script: |
core.setFailed('Plugin version and tag name are not equivalent!')
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
@@ -64,7 +54,7 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Docker meta - name: Docker meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v3
@@ -95,7 +85,7 @@ jobs:
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Bump versions - name: Bump versions
@@ -106,6 +96,7 @@ jobs:
git checkout main git checkout main
sh ./ci/bump-versions.sh sh ./ci/bump-versions.sh
git add charts/helm-dashboard/Chart.yaml git add charts/helm-dashboard/Chart.yaml
git add plugin.yaml
git commit -m "Increment chart versions [skip ci]" || echo "Already up-to-date" git commit -m "Increment chart versions [skip ci]" || echo "Already up-to-date"
git push -f || echo "Nothing to push!" git push -f || echo "Nothing to push!"
env: env:

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out
*.cov
# Dependency directories (remove the comment below to include it) # Dependency directories (remove the comment below to include it)
# vendor/ # vendor/

55
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,55 @@
# Contributing to Helm Dashboard
We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
- Reporting a bug
- Discussing the current state of the code
- Submitting a fix
- Proposing new features
## We Develop with GitHub
We use GitHub to host code, to track issues and feature requests, as well as accept pull requests.
## First-time contributors
We've tagged some issues to make it easy to get started :smile:
[Good first issues](https://github.com/komodorio/helm-dashboard/labels/good%20first%20issue)
Add a comment on the issue and wait for the issue to be assigned before you start working on it. This helps to avoid multiple people working on similar issues.
## All Code Changes Happen Through Pull Requests
Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests:
1. Fork the repo and create your branch from `main`.
2. If you've added code that should be tested, add tests.
3. Ensure the test suite passes.
4. Make sure your code lints.
5. Issue that pull request!
## Any contributions you make will be under the Apache License 2.0
In short, when you submit code changes, your submissions are understood to be under the same [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) that covers the project.
## Report bugs using GitHub's [issues](https://github.com/komodorio/helm-dashboard/issues)
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/komodorio/helm-dashboard/issues/new) and labeling it with the `bug` label. It's that easy!
**Great Bug Reports** tend to have:
- A quick summary and/or background
- Steps to reproduce
- Be specific!
- Give sample code if you can.
- What you expected would happen
- What actually happens
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
## License
By contributing, you agree that your contributions will be licensed under its Apache License 2.0.
## Questions?
Contact us on [Slack](https://komodorkommunity.slack.com).

View File

@@ -23,13 +23,11 @@ WORKDIR /build/src
RUN make build RUN make build
# Stage - runner # Stage - runner
FROM alpine/helm FROM alpine
EXPOSE 8080
# Python # Python
RUN apk add --update --no-cache python3 && python3 -m ensurepip && pip3 install --upgrade pip setuptools RUN apk add --update --no-cache python3 curl && python3 -m ensurepip && pip3 install --upgrade pip setuptools
# kubectl
RUN curl -o /bin/kubectl -vf -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && chmod +x /bin/kubectl && kubectl --help
# Trivy # Trivy
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.18.3 RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.18.3
@@ -40,6 +38,6 @@ RUN pip3 install checkov packaging==21.3 && checkov --version
COPY --from=builder /build/src/bin/dashboard /bin/helm-dashboard COPY --from=builder /build/src/bin/dashboard /bin/helm-dashboard
ENTRYPOINT ["/bin/helm-dashboard", "--no-browser", "--bind=0.0.0.0"] ENTRYPOINT ["/bin/helm-dashboard", "--no-browser", "--bind=0.0.0.0", "--port=8080"]
# docker build . -t komodorio/helm-dashboard:0.0.0 && kind load docker-image komodorio/helm-dashboard:0.0.0 # docker build . -t komodorio/helm-dashboard:0.0.0 && kind load docker-image komodorio/helm-dashboard:0.0.0

View File

@@ -4,7 +4,7 @@ VERSION ?= $(git describe --tags --always --dirty --match=v* 2> /dev/null || \
.PHONY: test .PHONY: test
test: ; $(info $(M) start unit testing...) @ test: ; $(info $(M) start unit testing...) @
@go test $$(go list ./... | grep -v /mocks/) --race -v -short -coverprofile=profile.cov @go test $$(go list ./... | grep -v /mocks/) --race -v -short -coverpkg=./... -coverprofile=profile.cov
@echo "\n*****************************" @echo "\n*****************************"
@echo "** TOTAL COVERAGE: $$(go tool cover -func profile.cov | grep total | grep -Eo '[0-9]+\.[0-9]+')% **" @echo "** TOTAL COVERAGE: $$(go tool cover -func profile.cov | grep total | grep -Eo '[0-9]+\.[0-9]+')% **"
@echo "*****************************\n" @echo "*****************************\n"

View File

@@ -1,19 +1,26 @@
# ![Helm Dashboard](pkg/dashboard/static/logo-header.svg#gh-light-mode-only) ![Helm Dashboard](pkg/dashboard/static/logo-header-inverted.svg#gh-dark-mode-only) <p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="pkg/dashboard/static/logo-header-inverted.svg">
<source media="(prefers-color-scheme: light)" srcset="pkg/dashboard/static/logo-header.svg#gh-light-mode-only">
<img alt="Helm Dashboard" src="pkg/dashboard/static/logo-header.svg#gh-light-mode-only">
</picture>
</p>
A simplified way of working with Helm. <p align="center">A simplified way of working with Helm.</p>
![GitHub contributors](https://img.shields.io/github/contributors/komodorio/helm-dashboard) [![GitHub issues](https://img.shields.io/github/issues-raw/komodorio/helm-dashboard)](https://github.com/komodorio/helm-dashboard/issues) ![GitHub stars](https://img.shields.io/github/stars/komodorio/helm-dashboard?style=social) ![GitHub closed issues](https://img.shields.io/github/issues-closed-raw/komodorio/helm-dashboard) ![GitHub pull requests](https://img.shields.io/github/issues-pr/komodorio/helm-dashboard) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/komodorio/helm-dashboard) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/komodorio/helm-dashboard) [![GitHub license](https://img.shields.io/github/license/komodorio/helm-dashboard)](https://github.com/komodorio/helm-dashboard)
<kbd>[<img src="screenshot.png" style="width: 100%; border: 1px solid silver;" border="1" alt="Screenshot">](screenshot.png)</kbd> <kbd>[<img src="screenshot.png" style="width: 100%; border: 1px solid silver;" border="1" alt="Screenshot">](screenshot.png)</kbd>
## What it Does? ## Description
_Helm Dashboard_ offers a UI-driven way to view the installed Helm charts, see their revision history and _Helm Dashboard_ is an **open-source project** which offers a UI-driven way to view the installed Helm charts, see their revision history and
corresponding k8s resources. Also, you can perform simple actions like roll back to a revision or upgrade to newer corresponding k8s resources. Also, you can perform simple actions like roll back to a revision or upgrade to newer
version. version.
This project is part of [Komodor's](https://komodor.com/?utm_campaign=Helm-Dash&utm_source=helm-dash-gh) vision of This project is part of [Komodor's](https://komodor.com/?utm_campaign=Helm-Dash&utm_source=helm-dash-gh) vision of
helping Kubernetes users to navigate and troubleshoot their clusters, the project is **NOT** an offical project by the [helm team](https://helm.sh/). helping Kubernetes users to navigate and troubleshoot their clusters, the project is **NOT** an offical project by the [helm team](https://helm.sh/).
Some of the key capabilities of the tool: Key capabilities of the tool:
- See all installed charts and their revision history - See all installed charts and their revision history
- See manifest diff of the past revisions - See manifest diff of the past revisions
@@ -21,12 +28,20 @@ Some of the key capabilities of the tool:
- Easy rollback or upgrade version with a clear and easy manifest diff - Easy rollback or upgrade version with a clear and easy manifest diff
- Integration with popular problem scanners - Integration with popular problem scanners
- Easy switch between multiple clusters - Easy switch between multiple clusters
- Can be used locally, or installed into Kubernetes cluster
- Does not require Helm or Kubectl installed
## Setup ## Setup
### Standalone Binary
Since version 1.0, the recommended install method is to just use standalone binary. It does not require Helm or kubectl to be installed.
Download the appropriate [release package](https://github.com/komodorio/helm-dashboard/releases) for your platform, unpack it and just run `dashboard` binary from it.
### Using Helm plugin manager ### Using Helm plugin manager
To install the plugin, simply run Helm command: To install dashboard as Helm plugin, simply run Helm command:
```shell ```shell
helm plugin install https://github.com/komodorio/helm-dashboard.git helm plugin install https://github.com/komodorio/helm-dashboard.git
@@ -65,7 +80,7 @@ This can also be specified using flag `--bind <host>`, for example `--bind=0.0.0
If your port 8080 is busy, you can specify a different port to use via `--port <number>` command-line flag. If your port 8080 is busy, you can specify a different port to use via `--port <number>` command-line flag.
If you need to limit the operations to a specific namespace, please use `--namespace=...` in your command-line. If you need to limit the operations to a specific namespace, please use `--namespace=...` in your command-line. You can specify multiple namespaces, separated by commas.
If you don't want browser tab to automatically open, add `--no-browser` flag in your command line. If you don't want browser tab to automatically open, add `--no-browser` flag in your command line.
@@ -77,9 +92,16 @@ If you want to increase the logging verbosity and see all the debug info, use th
The official helm chart is [available here](https://github.com/komodorio/helm-charts/blob/master/charts/helm-dashboard) The official helm chart is [available here](https://github.com/komodorio/helm-charts/blob/master/charts/helm-dashboard)
### Manual Installation
Download the appropriate [release package](https://github.com/komodorio/helm-dashboard/releases) for your platform, unpack it and just run `dashboard` binary from it. ## Execute Helm tests
For all the release(s) (installed helm charts), you can execute helm tests for that release. For the tests to execute successfully, you need to have existing tests for that helm chart.
You can execute `helm test` for the specific release as below:
![](screenshot_run_test.png)
The result of executed `helm test` for the release will be disapled as below:
![](screenshot_run_test_result.png)
## Scanner Integrations ## Scanner Integrations
@@ -97,9 +119,19 @@ button at the bottom of the dialog:
## Support Channels ## Support Channels
We have two main channels for supporting the Helm Dashboard We have two main channels for supporting the Helm Dashboard
users: [Slack community](https://join.slack.com/t/komodorkommunity/shared_invite/zt-1dm3cnkue-ov1Yh~_95teA35QNx5yuMg) for general conversations users: [Slack community](https://komodorkommunity.slack.com) for general conversations
and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real bugs. and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real bugs.
## Contributing
Kindly read our [Contributing Guide](CONTRIBUTING.md) to learn and understand about our development process, how to propose bug fixes and improvements, and how to build and test your changes to Helm Dashboard. <br>
## Contributors
<a href="https://github.com/komodorio/helm-dashboard/graphs/contributors">
<img src="https://contrib.rocks/image?repo=komodorio/helm-dashboard" />
</a>
## Local Dev Testing ## Local Dev Testing
Prerequisites: `helm` and `kubectl` binaries installed and operational. Prerequisites: `helm` and `kubectl` binaries installed and operational.

View File

@@ -5,5 +5,5 @@ name: helm-dashboard
description: A GUI Dashboard for Helm by Komodor description: A GUI Dashboard for Helm by Komodor
icon: "https://raw.githubusercontent.com/komodorio/helm-dashboard/main/pkg/dashboard/static/logo.svg" icon: "https://raw.githubusercontent.com/komodorio/helm-dashboard/main/pkg/dashboard/static/logo.svg"
version: 0.1.1 version: 0.1.2
appVersion: "0.3.0" appVersion: "0.3.1"

View File

@@ -12,6 +12,8 @@ helm upgrade --install my-release komodorio/helm-dashboard
This chart bootstraps a Helm Dashboard deployment on a [Kubernetes](http://kubernetes.io) cluster using the [Helm](https://helm.sh) package manager. This chart bootstraps a Helm Dashboard deployment on a [Kubernetes](http://kubernetes.io) cluster using the [Helm](https://helm.sh) package manager.
While installed inside cluster, Helm Dashboard will run some additional backgroud actions, for example, will automatically update Helm repositories. To enable that behavior locally, set `HD_CLUSTER_MODE` env variable.
## Prerequisites ## Prerequisites
- Kubernetes 1.16+ - Kubernetes 1.16+
@@ -39,6 +41,12 @@ helm uninstall my-release
The command removes all the Kubernetes components associated with the chart and deletes the release. The command removes all the Kubernetes components associated with the chart and deletes the release.
## Adding Authentication
The task of authentication and user control is out of scope for Helm Dashboard. Luckily, there are third-party solutions which are dedicated to provide that functionality.
For instance, you can place authentication proxy in front of Helm Dashboard, like this one: https://github.com/oauth2-proxy/oauth2-proxy
## Parameters ## Parameters
The following table lists the configurable parameters of the chart and their default values. The following table lists the configurable parameters of the chart and their default values.
@@ -65,7 +73,9 @@ The following table lists the configurable parameters of the chart and their def
| `dashboard.persistence.accessModes` | Persistent Volume access modes | `["ReadWriteOnce"]` | | `dashboard.persistence.accessModes` | Persistent Volume access modes | `["ReadWriteOnce"]` |
| `dashboard.persistence.storageClass` | Persistent Volume storage class | `""` | | `dashboard.persistence.storageClass` | Persistent Volume storage class | `""` |
| `dashboard.persistence.size` | Persistent Volume size | `100M` | | `dashboard.persistence.size` | Persistent Volume size | `100M` |
| `dashboard.persistence.hostPath` | Set path in case you want to use local host path volumes (not recommended in production) | `""` | | `dashboard.persistence.hostPath` | Set path in case you want to use local host path volumes (not recommended in production) | `""`
| `updateStrategy.type` | Set up update strategy for helm-dashboard installation. | `RollingUpdate` |
| `extraArgs` | Set the arguments to be supplied to the helm-dashboard binary | `[--no-browser, --bind=0.0.0.0]`
Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`.

View File

@@ -11,6 +11,7 @@ spec:
selector: selector:
matchLabels: matchLabels:
{{- include "helm-dashboard.selectorLabels" . | nindent 6 }} {{- include "helm-dashboard.selectorLabels" . | nindent 6 }}
strategy: {{- toYaml .Values.updateStrategy | nindent 4 }}
template: template:
metadata: metadata:
{{- with .Values.podAnnotations }} {{- with .Values.podAnnotations }}
@@ -29,6 +30,12 @@ spec:
{{- toYaml .Values.podSecurityContext | nindent 8 }} {{- toYaml .Values.podSecurityContext | nindent 8 }}
containers: containers:
- name: {{ .Chart.Name }} - name: {{ .Chart.Name }}
command:
- /bin/helm-dashboard
args:
{{- with .Values.extraArgs }}
{{- toYaml . | nindent 12 }}
{{- end }}
securityContext: securityContext:
{{- toYaml .Values.securityContext | nindent 12 }} {{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
@@ -40,6 +47,12 @@ spec:
value: /opt/dashboard/helm/config value: /opt/dashboard/helm/config
- name: HELM_DATA_HOME - name: HELM_DATA_HOME
value: /opt/dashboard/helm/data value: /opt/dashboard/helm/data
- name: DEBUG
value: {{- ternary " 1" "" .Values.debug }}
{{- if .Values.dashboard.namespace }}
- name: HELM_NAMESPACE
value: {{ .Values.dashboard.namespace }}
{{end}}
ports: ports:
- name: http - name: http
containerPort: 8080 containerPort: 8080
@@ -77,3 +90,4 @@ spec:
{{- else }} {{- else }}
emptyDir: { } emptyDir: { }
{{- end }} {{- end }}

View File

@@ -13,6 +13,8 @@ metadata:
spec: spec:
{{- if .Values.dashboard.persistence.hostPath }} {{- if .Values.dashboard.persistence.hostPath }}
storageClassName: "" storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.dashboard.persistence.storageClass }}"
{{- end }} {{- end }}
accessModes: accessModes:
{{- if not (empty .Values.dashboard.persistence.accessModes) }} {{- if not (empty .Values.dashboard.persistence.accessModes) }}

View File

@@ -19,11 +19,10 @@ metadata:
rules: rules:
- apiGroups: ["*"] - apiGroups: ["*"]
resources: ["*"] resources: ["*"]
verbs: ["get", "list", "watch"]
{{- if .Values.dashboard.allowWriteActions }} {{- if .Values.dashboard.allowWriteActions }}
- apiGroups: ["*"]
resources: ["*"]
verbs: ["get", "list", "watch", "create", "delete", "patch", "update"] verbs: ["get", "list", "watch", "create", "delete", "patch", "update"]
{{- else }}
verbs: ["get", "list", "watch"]
{{- end }} {{- end }}
--- ---
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1

View File

@@ -1,5 +1,8 @@
replicaCount: 1 replicaCount: 1
# Flag for setting environment to debug mode
debug: false
image: image:
repository: komodorio/helm-dashboard repository: komodorio/helm-dashboard
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
@@ -27,6 +30,10 @@ resources:
dashboard: dashboard:
allowWriteActions: true allowWriteActions: true
# default namespace for Helm operations
namespace: ""
persistence: persistence:
enabled: true enabled: true
@@ -61,6 +68,20 @@ dashboard:
## ##
size: 100M size: 100M
## @param.updateStrategy.type Set up update strategy for helm-dashboard installation.
## Set to Recreate if you use persistent volume that cannot be mounted by more than one pods to make sure the pods is destroyed first.
## ref: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy
## Example:
## updateStrategy:
## type: RollingUpdate
## rollingUpdate:
## maxSurge: 25%
## maxUnavailable: 25%
##
updateStrategy:
type: RollingUpdate
podAnnotations: {} podAnnotations: {}
podSecurityContext: {} podSecurityContext: {}
@@ -90,6 +111,11 @@ autoscaling:
nodeSelector: {} nodeSelector: {}
extraArgs:
- --no-browser
- --bind=0.0.0.0
tolerations: [] tolerations: []
affinity: {} affinity: {}

View File

@@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash -e
WORKING_DIRECTORY="$PWD" WORKING_DIRECTORY="$PWD"
@@ -9,6 +9,7 @@ WORKING_DIRECTORY="$PWD"
} }
sed -i -e "s/appVersion.*/appVersion: \"${APP_VERSION}\" /g" ${HELM_CHARTS_SOURCE}/Chart.yaml sed -i -e "s/appVersion.*/appVersion: \"${APP_VERSION}\" /g" ${HELM_CHARTS_SOURCE}/Chart.yaml
sed -i -e "s/version.*/version: \"${APP_VERSION}\" /g" plugin.yaml
CURRENT_VERSION=$(cat ${HELM_CHARTS_SOURCE}/Chart.yaml | grep 'version:' | awk '{print $2}') CURRENT_VERSION=$(cat ${HELM_CHARTS_SOURCE}/Chart.yaml | grep 'version:' | awk '{print $2}')
NEW_VERSION=$(echo $CURRENT_VERSION | awk -F. '{$NF = $NF + 1;} 1' | sed 's/ /./g') NEW_VERSION=$(echo $CURRENT_VERSION | awk -F. '{$NF = $NF + 1;} 1' | sed 's/ /./g')
sed -i -e "s/${CURRENT_VERSION}/${NEW_VERSION}/g" ${HELM_CHARTS_SOURCE}/Chart.yaml sed -i -e "s/${CURRENT_VERSION}/${NEW_VERSION}/g" ${HELM_CHARTS_SOURCE}/Chart.yaml

127
go.mod
View File

@@ -3,68 +3,165 @@ module github.com/komodorio/helm-dashboard
go 1.18 go 1.18
require ( require (
github.com/eko/gocache/v3 v3.1.1 github.com/eko/gocache/v3 v3.1.2
github.com/gin-gonic/gin v1.8.1 github.com/gin-gonic/gin v1.8.1
github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/go-version v1.6.0
github.com/hexops/gotextdiff v1.0.3 github.com/hexops/gotextdiff v1.0.3
github.com/jessevdk/go-flags v1.5.0 github.com/jessevdk/go-flags v1.5.0
github.com/joomcode/errorx v1.1.0
github.com/olekukonko/tablewriter v0.0.5 github.com/olekukonko/tablewriter v0.0.5
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/pkg/errors v0.9.1
github.com/rogpeppe/go-internal v1.8.0
github.com/sirupsen/logrus v1.9.0 github.com/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.8.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
helm.sh/helm/v3 v3.10.3 helm.sh/helm/v3 v3.10.3
k8s.io/apimachinery v0.25.2 k8s.io/api v0.26.0
k8s.io/apimachinery v0.26.0
k8s.io/cli-runtime v0.26.0
k8s.io/client-go v0.26.0
k8s.io/kubectl v0.26.0
) )
require ( require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/BurntSushi/toml v1.1.0 // indirect
github.com/MakeNowJust/heredoc v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.1.1 // indirect github.com/Masterminds/semver/v3 v3.1.1 // indirect
github.com/Masterminds/sprig/v3 v3.2.2 // indirect
github.com/Masterminds/squirrel v1.5.3 // indirect
github.com/XiaoMi/pegasus-go-client v0.0.0-20210427083443-f3b6b08bc4c2 // indirect github.com/XiaoMi/pegasus-go-client v0.0.0-20210427083443-f3b6b08bc4c2 // indirect
github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d // indirect github.com/bradfitz/gomemcache v0.0.0-20221031212613-62deef7fc822 // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/chai2010/gettext-go v1.0.2 // indirect
github.com/containerd/containerd v1.6.12 // indirect
github.com/cyphar/filepath-securejoin v0.2.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/docker/cli v20.10.17+incompatible // indirect
github.com/docker/distribution v2.8.1+incompatible // indirect
github.com/docker/docker v20.10.17+incompatible // indirect
github.com/docker/docker-credential-helpers v0.6.4 // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/emicklei/go-restful/v3 v3.9.0 // indirect
github.com/evanphx/json-patch v5.6.0+incompatible // indirect
github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
github.com/fatih/camelcase v1.0.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fvbommel/sortorder v1.0.1 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-errors/errors v1.0.1 // indirect
github.com/go-gorp/gorp/v3 v3.0.2 // indirect
github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/logr v1.2.3 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/swag v0.19.14 // indirect
github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.11.0 // indirect github.com/go-playground/validator/v10 v10.11.0 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.9.11 // indirect github.com/goccy/go-json v0.9.11 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect github.com/golang/protobuf v1.5.2 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/gnostic v0.5.7-v3refs // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/gofuzz v1.2.0 // indirect github.com/google/gofuzz v1.2.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.2.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/gosuri/uitable v0.0.4 // indirect
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/jmoiron/sqlx v1.3.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.2.1 // indirect github.com/leodido/go-urn v1.2.1 // indirect
github.com/lib/pq v1.10.6 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/spdystream v0.2.0 // indirect
github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect
github.com/pegasus-kv/thrift v0.13.0 // indirect github.com/pegasus-kv/thrift v0.13.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.3 // indirect github.com/pelletier/go-toml/v2 v2.0.3 // indirect
github.com/prometheus/client_golang v1.12.2 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/prometheus/client_model v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/common v0.33.0 // indirect github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/procfs v0.7.3 // indirect github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/rubenv/sql-migrate v1.1.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/spf13/cast v1.5.0 // indirect github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect github.com/ugorji/go/codec v1.2.7 // indirect
github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xlab/treeprint v1.1.0 // indirect
go.etcd.io/etcd/api/v3 v3.5.4 // indirect
go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect
golang.org/x/crypto v0.0.0-20220817201139-bc19a97f63c8 // indirect golang.org/x/crypto v0.0.0-20220817201139-bc19a97f63c8 // indirect
golang.org/x/exp v0.0.0-20220518171630-0b5c67f07fdf // indirect golang.org/x/exp v0.0.0-20221110155412-d0897a79cd37 // indirect
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c // indirect golang.org/x/net v0.3.1-0.20221206200815-1e63c2f08a10 // indirect
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect
golang.org/x/sys v0.0.0-20220818161305-2296e01440c6 // indirect golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.3.7 // indirect golang.org/x/sys v0.3.0 // indirect
golang.org/x/term v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21 // indirect
google.golang.org/grpc v1.49.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/klog/v2 v2.70.1 // indirect gotest.tools/v3 v3.4.0
k8s.io/utils v0.0.0-20220728103510-ee6ede2d64ed // indirect k8s.io/apiextensions-apiserver v0.25.2 // indirect
k8s.io/apiserver v0.25.2 // indirect
k8s.io/component-base v0.26.0 // indirect
k8s.io/klog/v2 v2.80.1 // indirect
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 // indirect
k8s.io/utils v0.0.0-20221107191617-1a15be271d1d
oras.land/oras-go v1.2.0 // indirect
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect
sigs.k8s.io/kustomize/api v0.12.1 // indirect
sigs.k8s.io/kustomize/kyaml v0.13.9 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
) )

546
go.sum

File diff suppressed because it is too large Load Diff

31
main.go
View File

@@ -1,8 +1,12 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"os/signal"
"strings"
"syscall"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jessevdk/go-flags" "github.com/jessevdk/go-flags"
@@ -23,8 +27,8 @@ type options struct {
NoBrowser bool `short:"b" long:"no-browser" description:"Do not attempt to open Web browser upon start"` NoBrowser bool `short:"b" long:"no-browser" description:"Do not attempt to open Web browser upon start"`
NoTracking bool `long:"no-analytics" description:"Disable user analytics (GA, DataDog etc.)"` NoTracking bool `long:"no-analytics" description:"Disable user analytics (GA, DataDog etc.)"`
BindHost string `long:"bind" description:"Host binding to start server (default: localhost)"` // default should be printed but not assigned as the precedence: flag > env > default BindHost string `long:"bind" description:"Host binding to start server (default: localhost)"` // default should be printed but not assigned as the precedence: flag > env > default
Port uint `short:"p" long:"port" description:"Port to start server on" default:"8080"` // TODO: better default port to clash less? Port uint `short:"p" long:"port" description:"Port to start server on" default:"8080"`
Namespace string `short:"n" long:"namespace" description:"Limit operations to a specific namespace"` Namespace string `short:"n" long:"namespace" description:"Namespace for HELM operations"`
} }
func main() { func main() {
@@ -42,12 +46,26 @@ func main() {
server := dashboard.Server{ server := dashboard.Server{
Version: version, Version: version,
Namespace: opts.Namespace, Namespaces: strings.Split(opts.Namespace, ","),
Address: fmt.Sprintf("%s:%d", opts.BindHost, opts.Port), Address: fmt.Sprintf("%s:%d", opts.BindHost, opts.Port),
Debug: opts.Verbose, Debug: opts.Verbose,
NoTracking: opts.NoTracking, NoTracking: opts.NoTracking,
} }
address, webServerDone := server.StartServer()
ctx, cancel := context.WithCancel(context.Background())
osSignal := make(chan os.Signal, 1)
signal.Notify(osSignal, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() {
oscall := <-osSignal
log.Warnf("Stopping on signal: %s\n", oscall)
cancel()
}()
address, webServerDone, err := server.StartServer(ctx, cancel)
if err != nil {
log.Fatalf("Failed to start Helm Dashboard: %+v", err)
}
if !opts.NoTracking { if !opts.NoTracking {
log.Infof("User analytics is collected to improve the quality, disable it with --no-analytics") log.Infof("User analytics is collected to improve the quality, disable it with --no-analytics")
@@ -69,7 +87,7 @@ func main() {
func parseFlags() options { func parseFlags() options {
ns := os.Getenv("HELM_NAMESPACE") ns := os.Getenv("HELM_NAMESPACE")
if ns == "default" { if ns == "default" { // it's how Helm passes to plugin the empty NS, we have to reset it back
ns = "" ns = ""
} }
@@ -92,7 +110,8 @@ func parseFlags() options {
} }
if len(args) > 0 { if len(args) > 0 {
panic("The program does not take argumants, see --help for usage") fmt.Println("The program does not take arguments, see --help for usage")
os.Exit(1)
} }
return opts return opts
} }

View File

@@ -1,23 +1,25 @@
package dashboard package dashboard
import ( import (
"context"
"embed" "embed"
"github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/handlers"
"github.com/komodorio/helm-dashboard/pkg/dashboard/objects"
log "github.com/sirupsen/logrus"
"html"
"net/http" "net/http"
"os" "os"
"path" "path"
"github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/handlers"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus"
) )
//go:embed static/* //go:embed static/*
var staticFS embed.FS var staticFS embed.FS
func noCache(c *gin.Context) { func noCache(c *gin.Context) {
if c.GetHeader("Cache-Control") == "" { // default policy is not to cache
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
}
c.Next() c.Next()
} }
@@ -26,33 +28,39 @@ func errorHandler(c *gin.Context) {
errs := "" errs := ""
for _, err := range c.Errors { for _, err := range c.Errors {
log.Debugf("Error: %s", err) log.Debugf("Error: %+v", err)
errs += err.Error() + "\n" errs += err.Error() + "\n"
} }
if errs != "" { if errs != "" {
c.String(http.StatusInternalServerError, errs) c.String(http.StatusInternalServerError, html.EscapeString(errs))
} }
} }
func contextSetter(data *subproc.DataLayer) gin.HandlerFunc { func contextSetter(data *objects.DataLayer) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
ctxName := ""
if ctx, ok := c.Request.Header["X-Kubecontext"]; ok { if ctx, ok := c.Request.Header["X-Kubecontext"]; ok {
log.Debugf("Setting current context to: %s", ctx) ctxName = ctx[0]
if data.KubeContext != ctx[0] { if err := data.SetContext(ctxName); err != nil {
err := data.Cache.Clear() c.String(http.StatusInternalServerError, err.Error())
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return return
} }
} }
data.KubeContext = ctx[0]
app, err := data.AppForCtx(ctxName)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return
} }
c.Set(handlers.APP, app)
c.Next() c.Next()
} }
} }
func NewRouter(abortWeb utils.ControlChan, data *subproc.DataLayer, debug bool) *gin.Engine { func NewRouter(abortWeb context.CancelFunc, data *objects.DataLayer, debug bool) *gin.Engine {
var api *gin.Engine var api *gin.Engine
if debug { if debug {
api = gin.New() api = gin.New()
@@ -71,10 +79,10 @@ func NewRouter(abortWeb utils.ControlChan, data *subproc.DataLayer, debug bool)
return api return api
} }
func configureRoutes(abortWeb utils.ControlChan, data *subproc.DataLayer, api *gin.Engine) { func configureRoutes(abortWeb context.CancelFunc, data *objects.DataLayer, api *gin.Engine) {
// server shutdown handler // server shutdown handler
api.DELETE("/", func(c *gin.Context) { api.DELETE("/", func(c *gin.Context) {
abortWeb <- struct{}{} abortWeb()
c.Status(http.StatusAccepted) c.Status(http.StatusAccepted)
}) })
@@ -83,11 +91,11 @@ func configureRoutes(abortWeb utils.ControlChan, data *subproc.DataLayer, api *g
c.IndentedJSON(http.StatusOK, data.GetStatus()) c.IndentedJSON(http.StatusOK, data.GetStatus())
}) })
api.GET("/api/cache", func(c *gin.Context) { api.GET("/api/cache", func(c *gin.Context) { // TODO: included into OpenAPI or not?
c.IndentedJSON(http.StatusOK, data.Cache) c.IndentedJSON(http.StatusOK, data.Cache)
}) })
api.DELETE("/api/cache", func(c *gin.Context) { api.DELETE("/api/cache", func(c *gin.Context) { // TODO: included into OpenAPI or not?
err := data.Cache.Clear() err := data.Cache.Clear()
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err) _ = c.AbortWithError(http.StatusBadRequest, err)
@@ -96,39 +104,63 @@ func configureRoutes(abortWeb utils.ControlChan, data *subproc.DataLayer, api *g
c.Status(http.StatusAccepted) c.Status(http.StatusAccepted)
}) })
api.POST("/diff", func(c *gin.Context) { // TODO: included into OpenAPI or not?
a := c.PostForm("a")
b := c.PostForm("b")
out := handlers.GetDiff(a, b, "current.yaml", "upgraded.yaml")
c.Header("Content-Type", "text/plain")
c.String(http.StatusOK, out)
})
api.GET("/api-docs", func(c *gin.Context) { // https://github.com/OAI/OpenAPI-Specification/search?q=api-docs
c.Redirect(http.StatusFound, "static/api-docs.html")
})
configureHelms(api.Group("/api/helm"), data) configureHelms(api.Group("/api/helm"), data)
configureKubectls(api.Group("/api/kube"), data) configureKubectls(api.Group("/api/k8s"), data)
configureScanners(api.Group("/api/scanners"), data) configureScanners(api.Group("/api/scanners"), data)
} }
func configureHelms(api *gin.RouterGroup, data *subproc.DataLayer) { func configureHelms(api *gin.RouterGroup, data *objects.DataLayer) {
h := handlers.HelmHandler{Data: data} h := handlers.HelmHandler{
Contexted: &handlers.Contexted{
api.GET("/charts", h.GetCharts) Data: data,
api.DELETE("/charts", h.Uninstall) },
api.GET("/charts/history", h.History)
api.GET("/charts/resources", h.Resources)
api.GET("/charts/:section", h.GetInfoSection)
api.GET("/charts/show", h.Show)
api.POST("/charts/install", h.Install)
api.POST("/charts/rollback", h.Rollback)
api.GET("/repo", h.RepoList)
api.POST("/repo", h.RepoAdd)
api.DELETE("/repo", h.RepoDelete)
api.GET("/repo/charts", h.RepoCharts)
api.GET("/repo/search", h.RepoSearch)
api.POST("/repo/update", h.RepoUpdate)
api.GET("/repo/values", h.RepoValues)
} }
func configureKubectls(api *gin.RouterGroup, data *subproc.DataLayer) { rels := api.Group("/releases")
h := handlers.KubeHandler{Data: data} rels.GET("", h.GetReleases)
rels.POST(":ns", h.Install)
rels.POST(":ns/:name", h.Upgrade)
rels.DELETE(":ns/:name", h.Uninstall)
rels.GET(":ns/:name/history", h.History)
rels.GET(":ns/:name/:section", h.GetInfoSection)
rels.GET(":ns/:name/resources", h.Resources)
rels.POST(":ns/:name/rollback", h.Rollback)
rels.POST(":ns/:name/test", h.RunTests)
repos := api.Group("/repositories")
repos.GET("", h.RepoList)
repos.POST("", h.RepoAdd)
repos.GET("/:name", h.RepoCharts)
repos.POST("/:name", h.RepoUpdate)
repos.DELETE("/:name", h.RepoDelete)
repos.GET("/latestver", h.RepoLatestVer) // TODO: use /versions in client insted and remove this?
repos.GET("/versions", h.RepoVersions)
repos.GET("/values", h.RepoValues)
}
func configureKubectls(api *gin.RouterGroup, data *objects.DataLayer) {
h := handlers.KubeHandler{
Contexted: &handlers.Contexted{
Data: data,
},
}
api.GET("/contexts", h.GetContexts) api.GET("/contexts", h.GetContexts)
api.GET("/resources/:kind", h.GetResourceInfo) api.GET("/:kind/get", h.GetResourceInfo)
api.GET("/describe/:kind", h.Describe) api.GET("/:kind/describe", h.Describe)
api.GET("/namespaces", h.GetNameSpaces) api.GET("/:kind/list", h.GetNameSpaces)
} }
func configureStatic(api *gin.Engine) { func configureStatic(api *gin.Engine) {
@@ -161,9 +193,13 @@ func configureStatic(api *gin.Engine) {
} }
} }
func configureScanners(api *gin.RouterGroup, data *subproc.DataLayer) { func configureScanners(api *gin.RouterGroup, data *objects.DataLayer) {
h := handlers.ScannersHandler{Data: data} h := handlers.ScannersHandler{
Contexted: &handlers.Contexted{
Data: data,
},
}
api.GET("", h.List) api.GET("", h.List)
api.POST("/manifests", h.ScanDraftManifest) api.POST("/manifests", h.ScanManifest)
api.GET("/resource/:kind", h.ScanResource) api.GET("/resource/:kind", h.ScanResource)
} }

412
pkg/dashboard/api_test.go Normal file
View File

@@ -0,0 +1,412 @@
package dashboard
import (
"github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/handlers"
"github.com/komodorio/helm-dashboard/pkg/dashboard/objects"
log "github.com/sirupsen/logrus"
"gotest.tools/v3/assert"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/cli"
kubefake "helm.sh/helm/v3/pkg/kube/fake"
"helm.sh/helm/v3/pkg/registry"
"helm.sh/helm/v3/pkg/storage"
"helm.sh/helm/v3/pkg/storage/driver"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
var inMemStorage *storage.Storage
var repoFile string
func TestMain(m *testing.M) { // fixture to set logging level via env variable
if os.Getenv("DEBUG") != "" {
log.SetLevel(log.DebugLevel)
log.Debugf("Set logging level")
}
inMemStorage = storage.Init(driver.NewMemory())
d, err := ioutil.TempDir("", "helm")
if err != nil {
panic(err)
}
repoFile = filepath.Join(d, "repositories.yaml")
m.Run()
inMemStorage = nil
repoFile = ""
}
func GetTestGinContext(w *httptest.ResponseRecorder) *gin.Context {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(w)
ctx.Request = &http.Request{
Header: make(http.Header),
}
return ctx
}
func TestNoCacheMiddleware(t *testing.T) {
w := httptest.NewRecorder()
con := GetTestGinContext(w)
noCache(con)
assert.Equal(t, w.Header().Get("Cache-Control"), "no-cache")
}
func TestEnableCacheControl(t *testing.T) {
w := httptest.NewRecorder()
con := GetTestGinContext(w)
// Sets deafault policy to `no-cache`
noCache(con)
h := handlers.HelmHandler{
Contexted: &handlers.Contexted{
Data: &objects.DataLayer{},
},
}
h.EnableClientCache(con)
assert.Equal(t, w.Header().Get("Cache-Control"), "max-age=43200")
}
func TestConfigureStatic(t *testing.T) {
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
// Create an API Engine
api := gin.Default()
// Configure static routes
configureStatic(api)
// Start the server
api.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
}
func TestConfigureRoutes(t *testing.T) {
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/status", nil)
if err != nil {
t.Fatal(err)
}
// Create a API Engine
api := gin.Default()
// Required arguements for route configuration
abortWeb := func() {}
data, err := objects.NewDataLayer([]string{"TestSpace"}, "T-1", NewHelmConfig)
if err != nil {
t.Fatal(err)
}
// Configure routes to API engine
configureRoutes(abortWeb, data, api)
// Start the server
api.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
}
func TestContextSetter(t *testing.T) {
w := httptest.NewRecorder()
con := GetTestGinContext(w)
// Required arguements
data, err := objects.NewDataLayer([]string{"TestSpace"}, "T-1", NewHelmConfig)
if err != nil {
t.Fatal(err)
}
// Set the context
ctxHandler := contextSetter(data)
ctxHandler(con)
appName, exists := con.Get("app")
if !exists {
t.Fatal("Value app doesn't exist in context")
}
tmp := handlers.Contexted{Data: data}
assert.Equal(t, appName, tmp.GetApp(con))
}
func TestNewRouter(t *testing.T) {
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/status", nil)
if err != nil {
t.Fatal(err)
}
// Required arguemnets
abortWeb := func() {}
data, err := objects.NewDataLayer([]string{"TestSpace"}, "T-1", NewHelmConfig)
if err != nil {
t.Fatal(err)
}
// Create a new router with the function
newRouter := NewRouter(abortWeb, data, false)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
}
func TestConfigureScanners(t *testing.T) {
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/scanners", nil)
if err != nil {
t.Fatal(err)
}
// Required arguemnets
data, err := objects.NewDataLayer([]string{"TestSpace"}, "T-1", NewHelmConfig)
if err != nil {
t.Fatal(err)
}
apiEngine := gin.Default()
configureScanners(apiEngine.Group("/api/scanners"), data)
apiEngine.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
}
func TestConfigureKubectls(t *testing.T) {
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/kube/contexts", nil)
if err != nil {
t.Fatal(err)
}
// Required arguemnets
data, err := objects.NewDataLayer([]string{"TestSpace"}, "T-1", NewHelmConfig)
if err != nil {
t.Fatal(err)
}
apiEngine := gin.Default()
// Required middleware for kubectl api configuration
apiEngine.Use(contextSetter(data))
configureKubectls(apiEngine.Group("/api/kube"), data)
apiEngine.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
}
func TestE2E(t *testing.T) {
// Initialize data layer
data, err := objects.NewDataLayer([]string{""}, "0.0.0-test", getFakeHelmConfig)
assert.NilError(t, err)
// Create a new router with the function
abortWeb := func() {}
newRouter := NewRouter(abortWeb, data, false)
// initially, we don't have any releases
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/api/helm/releases", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
assert.Equal(t, w.Body.String(), "[]")
// initially, we don't have any repositories
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/helm/repositories", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
assert.Equal(t, w.Body.String(), "[]")
// then we add one repository
w = httptest.NewRecorder()
form := url.Values{}
form.Add("name", "komodorio")
form.Add("url", "https://helm-charts.komodor.io")
req, err = http.NewRequest("POST", "/api/helm/repositories", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusNoContent)
assert.Equal(t, w.Body.String(), "")
// now, we have one repo
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/helm/repositories", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
assert.Equal(t, w.Body.String(), `[
{
"name": "komodorio",
"url": "https://helm-charts.komodor.io"
}
]`)
// what's the latest version of that chart
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/helm/repositories/latestver?name=helm-dashboard", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
// generate template for potential release
w = httptest.NewRecorder()
form = url.Values{}
form.Add("preview", "true")
form.Add("name", "release1")
form.Add("chart", "komodorio/helm-dashboard")
req, err = http.NewRequest("POST", "/api/helm/releases/test1", strings.NewReader(form.Encode()))
assert.NilError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
// install the release
w = httptest.NewRecorder()
form = url.Values{}
form.Add("name", "release1")
form.Add("chart", "komodorio/helm-dashboard")
req, err = http.NewRequest("POST", "/api/helm/releases/test1", strings.NewReader(form.Encode()))
assert.NilError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusAccepted)
// get list of releases
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/helm/releases", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
t.Logf("Release: %s", w.Body.String())
//assert.Equal(t, w.Body.String(), "[]")
// upgrade/reconfigure release
w = httptest.NewRecorder()
form = url.Values{}
form.Add("chart", "komodorio/helm-dashboard")
form.Add("values", "dashboard:\n allowWriteActions: true\n")
req, err = http.NewRequest("POST", "/api/helm/releases/test1/release1", strings.NewReader(form.Encode()))
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
assert.Equal(t, w.Code, http.StatusAccepted)
// get history of revisions for release
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/helm/releases/test1/release1/history", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
t.Logf("Revs: %s", w.Body.String())
//assert.Equal(t, w.Body.String(), "[]")
// get values for revision
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/helm/releases/test1/release1/values?revision=2&userDefined=true", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
//assert.Equal(t, w.Body.String(), "[]")
// rollback
w = httptest.NewRecorder()
form = url.Values{}
form.Add("revision", "1")
req, err = http.NewRequest("POST", "/api/helm/releases/test1/release1/rollback", strings.NewReader(form.Encode()))
assert.NilError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusAccepted)
// get manifest diff for release
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/helm/releases/test1/release1/manifests?revision=1&revisionDiff=2", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
//assert.Equal(t, w.Body.String(), "[]")
// delete repo
w = httptest.NewRecorder()
req, err = http.NewRequest("DELETE", "/api/helm/repositories/komodorio", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusNoContent)
// reconfigure release without repo connection
w = httptest.NewRecorder()
form = url.Values{}
form.Add("chart", "komodorio/helm-dashboard")
form.Add("values", "dashboard:\n allowWriteActions: false\n")
req, err = http.NewRequest("POST", "/api/helm/releases/test1/release1", strings.NewReader(form.Encode()))
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
assert.Equal(t, w.Code, http.StatusAccepted)
t.Logf("Upgraded: %s", w.Body.String())
// uninstall
w = httptest.NewRecorder()
req, err = http.NewRequest("DELETE", "/api/helm/releases/test1/release1", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusAccepted)
// check we don't have releases again
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", "/api/helm/releases", nil)
assert.NilError(t, err)
newRouter.ServeHTTP(w, req)
assert.Equal(t, w.Code, http.StatusOK)
assert.Equal(t, w.Body.String(), "[]")
}
func getFakeHelmConfig(settings *cli.EnvSettings, _ string) (*action.Configuration, error) {
settings.RepositoryConfig = repoFile
registryClient, err := registry.NewClient()
if err != nil {
return nil, err
}
return &action.Configuration{
Releases: inMemStorage,
KubeClient: &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: os.Stderr}},
Capabilities: chartutil.DefaultCapabilities,
RegistryClient: registryClient,
Log: log.Infof,
}, nil
}

View File

@@ -0,0 +1,31 @@
package handlers
import (
"github.com/gin-gonic/gin"
"github.com/joomcode/errorx"
"github.com/komodorio/helm-dashboard/pkg/dashboard/objects"
"net/http"
)
const APP = "app"
type Contexted struct {
Data *objects.DataLayer
}
func (h *Contexted) GetApp(c *gin.Context) *objects.Application {
var app *objects.Application
if a, ok := c.Get(APP); ok {
app = a.(*objects.Application)
} else {
err := errorx.IllegalState.New("No application context found")
_ = c.AbortWithError(http.StatusBadRequest, err)
return nil
}
return app
}
func (h *Contexted) EnableClientCache(c *gin.Context) {
c.Header("Cache-Control", "max-age=43200")
}

View File

@@ -2,35 +2,72 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"github.com/joomcode/errorx"
"github.com/komodorio/helm-dashboard/pkg/dashboard/objects"
"github.com/rogpeppe/go-internal/semver"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/repo"
helmtime "helm.sh/helm/v3/pkg/time"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils" "github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
) )
type HelmHandler struct { type HelmHandler struct {
Data *subproc.DataLayer *Contexted
} }
func (h *HelmHandler) GetCharts(c *gin.Context) { func (h *HelmHandler) getRelease(c *gin.Context) *objects.Release {
res, err := h.Data.ListInstalled() app := h.GetApp(c)
if app == nil {
return nil
}
rel, err := app.Releases.ByName(c.Param("ns"), c.Param("name"))
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return nil
}
return rel
}
func (h *HelmHandler) GetReleases(c *gin.Context) {
app := h.GetApp(c)
if app == nil {
return // sets error inside
}
rels, err := app.Releases.List()
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
res := []*ReleaseElement{}
for _, r := range rels {
res = append(res, HReleaseToJSON(r.Orig))
}
c.IndentedJSON(http.StatusOK, res) c.IndentedJSON(http.StatusOK, res)
} }
func (h *HelmHandler) Uninstall(c *gin.Context) { func (h *HelmHandler) Uninstall(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false) rel := h.getRelease(c)
if err != nil { if rel == nil {
_ = c.AbortWithError(http.StatusBadRequest, err) return // error state is set inside
return
} }
err = h.Data.ReleaseUninstall(qp.Namespace, qp.Name)
err := rel.Uninstall()
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -39,13 +76,18 @@ func (h *HelmHandler) Uninstall(c *gin.Context) {
} }
func (h *HelmHandler) Rollback(c *gin.Context) { func (h *HelmHandler) Rollback(c *gin.Context) {
qp, err := utils.GetQueryProps(c, true) rel := h.getRelease(c)
if rel == nil {
return // error state is set inside
}
revn, err := strconv.Atoi(c.PostForm("revision"))
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
err = h.Data.Rollback(qp.Namespace, qp.Name, qp.Revision) err = rel.Rollback(revn)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -54,73 +96,178 @@ func (h *HelmHandler) Rollback(c *gin.Context) {
} }
func (h *HelmHandler) History(c *gin.Context) { func (h *HelmHandler) History(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false) rel := h.getRelease(c)
if err != nil { if rel == nil {
_ = c.AbortWithError(http.StatusBadRequest, err) return // error state is set inside
return
} }
res, err := h.Data.ReleaseHistory(qp.Namespace, qp.Name) revs, err := rel.History()
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
res := []*HistoryElement{}
for _, r := range revs {
res = append(res, HReleaseToHistElem(r.Orig))
}
sort.Slice(res, func(i, j int) bool {
return res[i].Revision < res[j].Revision
})
c.IndentedJSON(http.StatusOK, res) c.IndentedJSON(http.StatusOK, res)
} }
func (h *HelmHandler) Resources(c *gin.Context) { func (h *HelmHandler) Resources(c *gin.Context) {
qp, err := utils.GetQueryProps(c, true) h.EnableClientCache(c)
rel := h.getRelease(c)
if rel == nil {
return // error state is set inside
}
res, err := objects.ParseManifests(rel.Orig.Manifest)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.IndentedJSON(http.StatusOK, res)
}
func (h *HelmHandler) RepoVersions(c *gin.Context) {
qp, err := utils.GetQueryProps(c)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err) _ = c.AbortWithError(http.StatusBadRequest, err)
return return
} }
res, err := h.Data.RevisionManifestsParsed(qp.Namespace, qp.Name, qp.Revision) app := h.GetApp(c)
if app == nil {
return // sets error inside
}
repos, err := app.Repositories.Containing(qp.Name)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
res := []*RepoChartElement{}
for _, r := range repos {
res = append(res, &RepoChartElement{
Name: r.Name,
Version: r.Version,
AppVersion: r.AppVersion,
Description: r.Description,
Repository: r.Annotations[objects.AnnRepo],
})
}
c.IndentedJSON(http.StatusOK, res) c.IndentedJSON(http.StatusOK, res)
} }
func (h *HelmHandler) RepoSearch(c *gin.Context) { func (h *HelmHandler) RepoLatestVer(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false) qp, err := utils.GetQueryProps(c)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err) _ = c.AbortWithError(http.StatusBadRequest, err)
return return
} }
res, err := h.Data.ChartRepoVersions(qp.Name) app := h.GetApp(c)
if app == nil {
return // sets error inside
}
rep, err := app.Repositories.Containing(qp.Name)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
c.IndentedJSON(http.StatusOK, res)
res := []*RepoChartElement{}
for _, r := range rep {
res = append(res, &RepoChartElement{
Name: r.Name,
Version: r.Version,
AppVersion: r.AppVersion,
Description: r.Description,
Repository: r.Annotations[objects.AnnRepo],
})
}
sort.Slice(res, func(i, j int) bool {
return semver.Compare(res[i].Version, res[j].Version) > 0
})
if len(res) > 0 {
c.IndentedJSON(http.StatusOK, res[:1])
} else {
c.Status(http.StatusNoContent)
}
} }
func (h *HelmHandler) RepoCharts(c *gin.Context) { func (h *HelmHandler) RepoCharts(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false) app := h.GetApp(c)
if err != nil { if app == nil {
_ = c.AbortWithError(http.StatusBadRequest, err) return // sets error inside
return
} }
res, err := h.Data.ChartRepoCharts(qp.Name) rep, err := app.Repositories.Get(c.Param("name"))
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
c.IndentedJSON(http.StatusOK, res)
}
func (h *HelmHandler) RepoUpdate(c *gin.Context) { charts, err := rep.Charts()
qp, err := utils.GetQueryProps(c, false)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
err = h.Data.ChartRepoUpdate(qp.Name) installed, err := app.Releases.List()
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
// TODO: enrich with installed
enrichRepoChartsWithInstalled(charts, installed)
sort.Slice(charts, func(i, j int) bool {
return charts[i].Name < charts[j].Name
})
c.IndentedJSON(http.StatusOK, charts)
}
func enrichRepoChartsWithInstalled(charts []*repo.ChartVersion, installed []*objects.Release) {
for _, rchart := range charts {
for _, rel := range installed {
if rchart.Metadata.Name == rel.Orig.Chart.Name() {
log.Debugf("Matched") // TODO: restore implementation
// TODO: there can be more than one
//rchart.InstalledNamespace = rel.Orig.Namespace
//rchart.InstalledName = rel.Orig.Name
}
}
}
}
func (h *HelmHandler) RepoUpdate(c *gin.Context) {
app := h.GetApp(c)
if app == nil {
return // sets error inside
}
rep, err := app.Repositories.Get(c.Param("name"))
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = rep.Update()
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -128,64 +275,125 @@ func (h *HelmHandler) RepoUpdate(c *gin.Context) {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func (h *HelmHandler) Show(c *gin.Context) { func (h *HelmHandler) Install(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false) app := h.GetApp(c)
if err != nil { if app == nil {
_ = c.AbortWithError(http.StatusBadRequest, err) return // sets error inside
return
} }
res, err := h.Data.ShowChart(qp.Name) values := map[string]interface{}{}
err := yaml.Unmarshal([]byte(c.PostForm("values")), &values)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
c.IndentedJSON(http.StatusOK, res) justTemplate := c.PostForm("preview") == "true"
ns := c.Param("ns")
if ns == "[empty]" {
ns = ""
} }
rel, err := app.Releases.Install(ns, c.PostForm("name"), c.PostForm("chart"), c.PostForm("version"), justTemplate, values)
func (h *HelmHandler) Install(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
justTemplate := c.Query("flag") != "true"
isInitial := c.Query("initial") != "true"
out, err := h.Data.ChartInstall(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), justTemplate, c.PostForm("values"), isInitial)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
if justTemplate { if justTemplate {
manifests := "" c.IndentedJSON(http.StatusOK, rel)
if isInitial { } else {
manifests, err = h.Data.RevisionManifests(qp.Namespace, qp.Name, 0, false) c.IndentedJSON(http.StatusAccepted, rel)
}
}
func (h *HelmHandler) Upgrade(c *gin.Context) {
app := h.GetApp(c)
if app == nil {
return // sets error inside
}
existing, err := app.Releases.ByName(c.Param("ns"), c.Param("name"))
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
values := map[string]interface{}{}
err = yaml.Unmarshal([]byte(c.PostForm("values")), &values)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
justTemplate := c.PostForm("preview") == "true"
rel, err := existing.Upgrade(c.PostForm("chart"), c.PostForm("version"), justTemplate, values)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
if justTemplate {
c.IndentedJSON(http.StatusOK, rel)
} else {
c.IndentedJSON(http.StatusAccepted, rel)
}
}
func (h *HelmHandler) RunTests(c *gin.Context) {
rel := h.getRelease(c)
if rel == nil {
return // error state is set inside
}
out, err := rel.RunTests()
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.String(http.StatusOK, out)
}
func (h *HelmHandler) GetInfoSection(c *gin.Context) {
if c.Query("revision") != "" { // don't cache if latest is requested
h.EnableClientCache(c)
}
rel := h.getRelease(c)
if rel == nil {
return // error state is set inside
}
revn, err := strconv.Atoi(c.Query("revision"))
if c.Query("revision") != "" && err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
rev, err := rel.GetRev(revn)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
var revDiff *objects.Release
revS := c.Query("revisionDiff")
if revS != "" {
revN, err := strconv.Atoi(revS)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
revDiff, err = rel.GetRev(revN)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
} }
out = subproc.GetDiff(strings.TrimSpace(manifests), out, "current.yaml", "upgraded.yaml")
} else {
c.Header("Content-Type", "application/json")
}
c.String(http.StatusAccepted, out) flag := c.Query("userDefined") == "true"
}
func (h *HelmHandler) GetInfoSection(c *gin.Context) { res, err := h.handleGetSection(rev, c.Param("section"), revDiff, flag)
qp, err := utils.GetQueryProps(c, true)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
flag := c.Query("flag") == "true"
rDiff := c.Query("revisionDiff")
res, err := handleGetSection(h.Data, c.Param("section"), rDiff, qp, flag)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -194,25 +402,53 @@ func (h *HelmHandler) GetInfoSection(c *gin.Context) {
} }
func (h *HelmHandler) RepoValues(c *gin.Context) { func (h *HelmHandler) RepoValues(c *gin.Context) {
out, err := h.Data.ShowValues(c.Query("chart"), c.Query("version")) h.EnableClientCache(c)
app := h.GetApp(c)
if app == nil {
return // sets error inside
}
out, err := app.Repositories.GetChartValues(c.Query("chart"), c.Query("version"))
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
c.String(http.StatusOK, out) c.String(http.StatusOK, out)
} }
func (h *HelmHandler) RepoList(c *gin.Context) { func (h *HelmHandler) RepoList(c *gin.Context) {
out, err := h.Data.ChartRepoList() app := h.GetApp(c)
if app == nil {
return // sets error inside
}
repos, err := app.Repositories.List()
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
out := []RepositoryElement{}
for _, r := range repos {
out = append(out, RepositoryElement{
Name: r.Orig.Name,
URL: r.Orig.URL,
})
}
c.IndentedJSON(http.StatusOK, out) c.IndentedJSON(http.StatusOK, out)
} }
func (h *HelmHandler) RepoAdd(c *gin.Context) { func (h *HelmHandler) RepoAdd(c *gin.Context) {
_, err := h.Data.ChartRepoAdd(c.PostForm("name"), c.PostForm("url")) app := h.GetApp(c)
if app == nil {
return // sets error inside
}
// TODO: more repo options to accept
err := app.Repositories.Add(c.PostForm("name"), c.PostForm("url"))
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -221,13 +457,12 @@ func (h *HelmHandler) RepoAdd(c *gin.Context) {
} }
func (h *HelmHandler) RepoDelete(c *gin.Context) { func (h *HelmHandler) RepoDelete(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false) app := h.GetApp(c)
if err != nil { if app == nil {
_ = c.AbortWithError(http.StatusBadRequest, err) return // sets error inside
return
} }
_, err = h.Data.ChartRepoDelete(qp.Name) err := app.Repositories.Delete(c.Param("name"))
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -235,11 +470,30 @@ func (h *HelmHandler) RepoDelete(c *gin.Context) {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func handleGetSection(data *subproc.DataLayer, section string, rDiff string, qp *utils.QueryProps, flag bool) (string, error) { func (h *HelmHandler) handleGetSection(rel *objects.Release, section string, rDiff *objects.Release, flag bool) (string, error) {
sections := map[string]subproc.SectionFn{ sections := map[string]objects.SectionFn{
"manifests": data.RevisionManifests, "manifests": func(qp *release.Release, b bool) (string, error) { return qp.Manifest, nil },
"values": data.RevisionValues, "notes": func(qp *release.Release, b bool) (string, error) { return qp.Info.Notes, nil },
"notes": data.RevisionNotes, "values": func(qp *release.Release, b bool) (string, error) {
allVals := qp.Config
if !b {
merged, err := chartutil.CoalesceValues(qp.Chart, qp.Config)
if err != nil {
return "", errorx.Decorate(err, "failed to merge chart vals with user defined")
}
allVals = merged
}
if len(allVals) > 0 {
data, err := yaml.Marshal(allVals)
if err != nil {
return "", errorx.Decorate(err, "failed to serialize values into YAML")
}
return string(data), nil
}
return "", nil
},
} }
functor, found := sections[section] functor, found := sections[section]
@@ -247,27 +501,130 @@ func handleGetSection(data *subproc.DataLayer, section string, rDiff string, qp
return "", errors.New("unsupported section: " + section) return "", errors.New("unsupported section: " + section)
} }
if rDiff != "" { if rDiff != nil {
cRevDiff, err := strconv.Atoi(rDiff)
if err != nil {
return "", err
}
ext := ".yaml" ext := ".yaml"
if section == "notes" { if section == "notes" {
ext = ".txt" ext = ".txt"
} }
res, err := subproc.RevisionDiff(functor, ext, qp.Namespace, qp.Name, cRevDiff, qp.Revision, flag) res, err := RevisionDiff(functor, ext, rDiff.Orig, rel.Orig, flag)
if err != nil { if err != nil {
return "", err return "", err
} }
return res, nil return res, nil
} }
res, err := functor(qp.Namespace, qp.Name, qp.Revision, flag) res, err := functor(rel.Orig, flag)
if err != nil { if err != nil {
return "", err return "", errorx.Decorate(err, "failed to get section info")
} }
return res, nil return res, nil
} }
type RepoChartElement struct {
Name string `json:"name"`
Version string `json:"version"`
AppVersion string `json:"app_version"`
Description string `json:"description"`
InstalledNamespace string `json:"installed_namespace"` // custom addition on top of Helm
InstalledName string `json:"installed_name"` // custom addition on top of Helm
Repository string `json:"repository"`
}
func HReleaseToJSON(o *release.Release) *ReleaseElement {
return &ReleaseElement{
Name: o.Name,
Namespace: o.Namespace,
Revision: strconv.Itoa(o.Version),
Updated: o.Info.LastDeployed,
Status: o.Info.Status,
Chart: fmt.Sprintf("%s-%s", o.Chart.Name(), o.Chart.Metadata.Version),
AppVersion: o.Chart.AppVersion(),
Icon: o.Chart.Metadata.Icon,
Description: o.Chart.Metadata.Description,
}
}
type ReleaseElement struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Revision string `json:"revision"`
Updated helmtime.Time `json:"updated"`
Status release.Status `json:"status"`
Chart string `json:"chart"`
AppVersion string `json:"app_version"`
Icon string `json:"icon"`
Description string `json:"description"`
}
type RepositoryElement struct {
Name string `json:"name"`
URL string `json:"url"`
}
type HistoryElement struct {
Revision int `json:"revision"`
Updated helmtime.Time `json:"updated"`
Status release.Status `json:"status"`
Chart string `json:"chart"`
AppVersion string `json:"app_version"`
Description string `json:"description"`
ChartName string `json:"chart_name"` // custom addition on top of Helm
ChartVer string `json:"chart_ver"` // custom addition on top of Helm
HasTests bool `json:"has_tests"`
}
func HReleaseToHistElem(o *release.Release) *HistoryElement {
return &HistoryElement{
Revision: o.Version,
Updated: o.Info.LastDeployed,
Status: o.Info.Status,
Chart: fmt.Sprintf("%s-%s", o.Chart.Name(), o.Chart.Metadata.Version),
AppVersion: o.Chart.AppVersion(),
Description: o.Info.Description,
ChartName: o.Chart.Name(),
ChartVer: o.Chart.Metadata.Version,
HasTests: releaseHasTests(o),
}
}
func RevisionDiff(functor objects.SectionFn, ext string, revision1 *release.Release, revision2 *release.Release, flag bool) (string, error) {
if revision1 == nil || revision2 == nil {
log.Debugf("One of revisions is nil: %v %v", revision1, revision2)
return "", nil
}
manifest1, err := functor(revision1, flag)
if err != nil {
return "", err
}
manifest2, err := functor(revision2, flag)
if err != nil {
return "", err
}
diff := GetDiff(manifest1, manifest2, strconv.Itoa(revision1.Version)+ext, strconv.Itoa(revision2.Version)+ext)
return diff, nil
}
func GetDiff(text1 string, text2 string, name1 string, name2 string) string {
edits := myers.ComputeEdits(span.URIFromPath(""), text1, text2)
unified := gotextdiff.ToUnified(name1, name2, text1, edits)
diff := fmt.Sprint(unified)
log.Debugf("The diff is: %s", diff)
return diff
}
func releaseHasTests(o *release.Release) bool {
for _, h := range o.Hooks {
for _, e := range h.Events {
if e == release.HookTest {
return true
}
}
}
return false
}

View File

@@ -1,20 +1,25 @@
package handlers package handlers
import ( import (
"github.com/joomcode/errorx"
"k8s.io/apimachinery/pkg/api/errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils" "github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v12 "k8s.io/apimachinery/pkg/apis/testapigroup/v1" v12 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
) )
type KubeHandler struct { type KubeHandler struct {
Data *subproc.DataLayer *Contexted
} }
func (h *KubeHandler) GetContexts(c *gin.Context) { func (h *KubeHandler) GetContexts(c *gin.Context) {
app := h.GetApp(c)
if app == nil {
return // sets error inside
}
res, err := h.Data.ListContexts() res, err := h.Data.ListContexts()
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
@@ -24,21 +29,33 @@ func (h *KubeHandler) GetContexts(c *gin.Context) {
} }
func (h *KubeHandler) GetResourceInfo(c *gin.Context) { func (h *KubeHandler) GetResourceInfo(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false) qp, err := utils.GetQueryProps(c)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err) _ = c.AbortWithError(http.StatusBadRequest, err)
return return
} }
res, err := h.Data.GetResource(qp.Namespace, &v12.Carp{ app := h.GetApp(c)
TypeMeta: v1.TypeMeta{Kind: c.Param("kind")}, if app == nil {
ObjectMeta: v1.ObjectMeta{Name: qp.Name}, return // sets error inside
}) }
if err != nil {
res, err := app.K8s.GetResourceInfo(c.Param("kind"), qp.Namespace, qp.Name)
if errors.IsNotFound(err) {
res = &v12.Carp{Status: v12.CarpStatus{Phase: "NotFound", Message: err.Error()}}
//_ = c.AbortWithError(http.StatusNotFound, err)
//return
} else if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
EnhanceStatus(res)
c.IndentedJSON(http.StatusOK, res)
}
func EnhanceStatus(res *v12.Carp) {
// custom logic to provide most meaningful status for the resource // custom logic to provide most meaningful status for the resource
if res.Status.Phase == "Active" || res.Status.Phase == "Error" { if res.Status.Phase == "Active" || res.Status.Phase == "Error" {
_ = res.Name + "" _ = res.Name + ""
@@ -52,18 +69,21 @@ func (h *KubeHandler) GetResourceInfo(c *gin.Context) {
} else if res.Status.Phase == "" { } else if res.Status.Phase == "" {
res.Status.Phase = "Exists" res.Status.Phase = "Exists"
} }
c.IndentedJSON(http.StatusOK, res)
} }
func (h *KubeHandler) Describe(c *gin.Context) { func (h *KubeHandler) Describe(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false) qp, err := utils.GetQueryProps(c)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err) _ = c.AbortWithError(http.StatusBadRequest, err)
return return
} }
res, err := h.Data.DescribeResource(qp.Namespace, c.Param("kind"), qp.Name) app := h.GetApp(c)
if app == nil {
return // sets error inside
}
res, err := app.K8s.DescribeResource(c.Param("kind"), qp.Namespace, qp.Name)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -73,11 +93,21 @@ func (h *KubeHandler) Describe(c *gin.Context) {
} }
func (h *KubeHandler) GetNameSpaces(c *gin.Context) { func (h *KubeHandler) GetNameSpaces(c *gin.Context) {
res, err := h.Data.GetNameSpaces() if c.Param("kind") != "namespaces" {
_ = c.AbortWithError(http.StatusBadRequest, errorx.AssertionFailed.New("Only 'namespaces' kind is allowed for listing"))
return
}
app := h.GetApp(c)
if app == nil {
return // sets error inside
}
res, err := app.K8s.GetNameSpaces()
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
c.JSON(http.StatusOK, res) c.IndentedJSON(http.StatusOK, res)
} }

View File

@@ -8,7 +8,7 @@ import (
) )
type ScannersHandler struct { type ScannersHandler struct {
Data *subproc.DataLayer *Contexted
} }
func (h *ScannersHandler) List(c *gin.Context) { func (h *ScannersHandler) List(c *gin.Context) {
@@ -26,23 +26,10 @@ func (h *ScannersHandler) List(c *gin.Context) {
c.IndentedJSON(http.StatusOK, res) c.IndentedJSON(http.StatusOK, res)
} }
func (h *ScannersHandler) ScanDraftManifest(c *gin.Context) { func (h *ScannersHandler) ScanManifest(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
reuseVals := c.Query("initial") != "true"
mnf, err := h.Data.ChartInstall(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), true, c.PostForm("values"), reuseVals)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
reps := map[string]*subproc.ScanResults{} reps := map[string]*subproc.ScanResults{}
for _, scanner := range h.Data.Scanners { for _, scanner := range h.Data.Scanners {
sr, err := scanner.ScanManifests(mnf) sr, err := scanner.ScanManifests(c.PostForm("manifest"))
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -55,7 +42,7 @@ func (h *ScannersHandler) ScanDraftManifest(c *gin.Context) {
} }
func (h *ScannersHandler) ScanResource(c *gin.Context) { func (h *ScannersHandler) ScanResource(c *gin.Context) {
qp, err := utils.GetQueryProps(c, false) qp, err := utils.GetQueryProps(c)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err) _ = c.AbortWithError(http.StatusBadRequest, err)
return return

View File

@@ -0,0 +1,49 @@
package objects
import (
"github.com/joomcode/errorx"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"
// Import to initialize client auth plugins.
// From https://github.com/kubernetes/client-go/issues/242
_ "k8s.io/client-go/plugin/pkg/client/auth"
)
type HelmConfigGetter = func(sett *cli.EnvSettings, ns string) (*action.Configuration, error)
type HelmNSConfigGetter = func(ns string) (*action.Configuration, error)
type Application struct {
Settings *cli.EnvSettings
HelmConfig HelmNSConfigGetter
K8s *K8s
Releases *Releases
Repositories *Repositories
}
func NewApplication(settings *cli.EnvSettings, helmConfig HelmNSConfigGetter, namespaces []string) (*Application, error) {
hc, err := helmConfig(settings.Namespace())
if err != nil {
return nil, errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
k8s, err := NewK8s(hc, namespaces)
if err != nil {
return nil, errorx.Decorate(err, "failed to get k8s client")
}
return &Application{
HelmConfig: helmConfig,
K8s: k8s,
Releases: &Releases{
Namespaces: namespaces,
Settings: settings,
HelmConfig: helmConfig,
},
Repositories: &Repositories{
Settings: settings,
HelmConfig: hc,
},
}, nil
}

View File

@@ -1,4 +1,4 @@
package subproc package objects
import ( import (
"context" "context"
@@ -12,15 +12,6 @@ import (
type CacheKey = string type CacheKey = string
const CacheKeyRelList CacheKey = "installed-releases-list"
const CacheKeyShowChart CacheKey = "show-chart"
const CacheKeyRelHistory CacheKey = "release-history"
const CacheKeyRevManifests CacheKey = "rev-manifests"
const CacheKeyRevNotes CacheKey = "rev-notes"
const CacheKeyRevValues CacheKey = "rev-values"
const CacheKeyRepoChartValues CacheKey = "chart-values"
const CacheKeyAllRepos CacheKey = "all-repos"
type Cache struct { type Cache struct {
Marshaler *marshaler.Marshaler `json:"-"` Marshaler *marshaler.Marshaler `json:"-"`
HitCount int HitCount int
@@ -83,18 +74,3 @@ func (c *Cache) Clear() error {
c.MissCount = 0 c.MissCount = 0
return c.Marshaler.Clear(context.Background()) return c.Marshaler.Clear(context.Background())
} }
func cacheTagRelease(namespace string, name string) CacheKey {
return "release" + "\v" + namespace + "\v" + name
}
func cacheTagRepoVers(chartName string) CacheKey {
return "repo-versions" + "\v" + chartName
}
func cacheTagRepoCharts(name string) CacheKey {
return "repo-charts" + "\v" + name
}
func cacheTagRepoName(name string) CacheKey {
return "repo-name" + "\v" + name
}

View File

@@ -0,0 +1,232 @@
package objects
import (
"bytes"
"context"
"encoding/json"
"sync"
"time"
"github.com/joomcode/errorx"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/release"
"io"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"k8s.io/client-go/tools/clientcmd"
)
type DataLayer struct {
KubeContext string
Scanners []subproc.Scanner
StatusInfo *StatusInfo
Namespaces []string
Cache *Cache
ConfGen HelmConfigGetter
appPerContext map[string]*Application
appPerContextMx *sync.Mutex
}
type StatusInfo struct {
CurVer string
LatestVer string
Analytics bool
CacheHitRatio float64
ClusterMode bool
}
func NewDataLayer(ns []string, ver string, cg HelmConfigGetter) (*DataLayer, error) {
if cg == nil {
return nil, errors.New("HelmConfigGetter can't be nil")
}
return &DataLayer{
Namespaces: ns,
Cache: NewCache(),
StatusInfo: &StatusInfo{
CurVer: ver,
Analytics: false,
},
ConfGen: cg,
appPerContext: map[string]*Application{},
appPerContextMx: new(sync.Mutex),
}, nil
}
func (d *DataLayer) ListContexts() ([]KubeContext, error) {
res := []KubeContext{}
if d.StatusInfo.ClusterMode {
return res, nil
}
cfg, err := clientcmd.NewDefaultPathOptions().GetStartingConfig()
if err != nil {
return nil, errorx.Decorate(err, "failed to get kubectl config")
}
for name, ctx := range cfg.Contexts {
res = append(res, KubeContext{
IsCurrent: cfg.CurrentContext == name,
Name: name,
Cluster: ctx.Cluster,
AuthInfo: ctx.AuthInfo,
Namespace: ctx.Namespace,
})
}
return res, nil
}
func (d *DataLayer) GetStatus() *StatusInfo {
sum := float64(d.Cache.HitCount + d.Cache.MissCount)
if sum > 0 {
d.StatusInfo.CacheHitRatio = float64(d.Cache.HitCount) / sum
} else {
d.StatusInfo.CacheHitRatio = 0
}
return d.StatusInfo
}
type SectionFn = func(*release.Release, bool) (string, error)
func ParseManifests(out string) ([]*v1.Carp, error) {
dec := yaml.NewDecoder(bytes.NewReader([]byte(out)))
res := make([]*v1.Carp, 0)
var tmp interface{}
for {
err := dec.Decode(&tmp)
if err == io.EOF {
break
}
if err != nil {
return nil, errorx.Decorate(err, "failed to parse manifest document #%d", len(res)+1)
}
// k8s libs uses only JSON tags defined, say hello to https://github.com/go-yaml/yaml/issues/424
// we can juggle it
jsoned, err := json.Marshal(tmp)
if err != nil {
return nil, err
}
var doc v1.Carp
err = json.Unmarshal(jsoned, &doc)
if err != nil {
return nil, err
}
if doc.Kind == "" {
log.Warnf("Manifest piece is not k8s resource: %s", jsoned)
continue
}
res = append(res, &doc)
}
return res, nil
}
func (d *DataLayer) SetContext(ctx string) error {
if d.KubeContext != ctx {
err := d.Cache.Clear()
if err != nil {
return errors.Wrap(err, "failed to set context")
}
}
d.KubeContext = ctx
return nil
}
func (d *DataLayer) AppForCtx(ctx string) (*Application, error) {
d.appPerContextMx.Lock()
defer d.appPerContextMx.Unlock()
app, ok := d.appPerContext[ctx]
if !ok {
settings := cli.New()
settings.KubeContext = ctx
settings.SetNamespace(d.nsForCtx(ctx))
cfgGetter := func(ns string) (*action.Configuration, error) {
return d.ConfGen(settings, ns)
}
a, err := NewApplication(settings, cfgGetter, d.Namespaces)
if err != nil {
return nil, errorx.Decorate(err, "Failed to create application for context '%s'", ctx)
}
app = a
d.appPerContext[ctx] = app
}
return app, nil
}
func (d *DataLayer) nsForCtx(ctx string) string {
lst, err := d.ListContexts()
if err != nil {
log.Debugf("Failed to get contexts for NS lookup: %+v", err)
}
for _, c := range lst {
if c.Name == ctx {
return c.Namespace
}
}
log.Debugf("Strange: no context found for '%s'", ctx)
return ""
}
func (d *DataLayer) PeriodicTasks(ctx context.Context) {
if !d.StatusInfo.ClusterMode { // TODO: maybe have a separate flag for that?
log.Debugf("Not in cluster mode, not starting background tasks")
return
}
// auto-update repos
go d.loopUpdateRepos(ctx, 10*time.Minute) // TODO: parameterize interval?
// auto-scan
}
func (d *DataLayer) loopUpdateRepos(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
for {
app, err := d.AppForCtx("")
if err != nil {
log.Warnf("Failed to get app object while in background repo update: %v", err)
break // no point in retrying
} else {
repos, err := app.Repositories.List()
if err != nil {
log.Warnf("Failed to get list of repos while in background update: %v", err)
}
for _, repo := range repos {
err := repo.Update()
if err != nil {
log.Warnf("Failed to update repo %s: %v", repo.Orig.Name, err)
}
}
}
select {
case <-ctx.Done():
ticker.Stop()
return
case <-ticker.C:
continue
}
}
log.Debugf("Update repo loop done.")
}

View File

@@ -0,0 +1,58 @@
package objects
import (
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"
)
func TestNewDataLayer(t *testing.T) {
testCases := []struct {
name string
namespaces []string
version string
helmConfig HelmConfigGetter
errorExpected bool
}{
{
name: "should return error when helm config is nil",
namespaces: []string{"namespace1", "namespace2"},
version: "1.0.0",
helmConfig: nil,
errorExpected: true,
},
{
name: "should return data layer when all parameters are correct",
namespaces: []string{
"namespace1",
"namespace2",
},
version: "1.0.0",
helmConfig: func(sett *cli.EnvSettings, ns string) (*action.Configuration, error) {
return &action.Configuration{}, nil
},
errorExpected: false,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
dl, err := NewDataLayer(tt.namespaces, tt.version, tt.helmConfig)
if tt.errorExpected {
assert.Error(t, err, "Expected error but got nil")
} else {
assert.Nil(t, err, "NewDataLayer returned an error: %v", err)
assert.NotNil(t, dl, "NewDataLayer returned nil")
assert.Equal(t, tt.namespaces, dl.Namespaces, "NewDataLayer returned incorrect namespaces: %v", dl.Namespaces)
assert.NotNil(t, dl.Cache, "NewDataLayer returned nil cache")
assert.Equal(t, tt.version, dl.StatusInfo.CurVer, "NewDataLayer returned incorrect version: %v", dl.StatusInfo.CurVer)
assert.False(t, dl.StatusInfo.Analytics, "NewDataLayer returned incorrect version: %v", dl.StatusInfo.CurVer)
assert.NotNil(t, dl.appPerContext, "NewDataLayer returned nil appPerContext")
assert.NotNil(t, dl.ConfGen, "NewDataLayer returned nil ConfGen")
}
})
}
}

View File

@@ -0,0 +1,198 @@
package objects
import (
"context"
"encoding/json"
"github.com/joomcode/errorx"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/kube"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
testapiv1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
describecmd "k8s.io/kubectl/pkg/cmd/describe"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/describe"
"k8s.io/utils/strings/slices"
"sort"
)
type KubeContext struct {
IsCurrent bool
Name string
Cluster string
AuthInfo string
Namespace string
}
// maps action.RESTClientGetter into genericclioptions.RESTClientGetter
type cfgProxyObject struct {
Impl action.RESTClientGetter
}
func (p *cfgProxyObject) ToRESTConfig() (*rest.Config, error) {
return p.Impl.ToRESTConfig()
}
func (p *cfgProxyObject) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
return p.Impl.ToDiscoveryClient()
}
func (p *cfgProxyObject) ToRESTMapper() (meta.RESTMapper, error) {
return p.Impl.ToRESTMapper()
}
func (p *cfgProxyObject) ToRawKubeConfigLoader() clientcmd.ClientConfig {
panic("Not implemented, stub")
}
type K8s struct {
Namespaces []string
Factory kube.Factory
RestClientGetter genericclioptions.RESTClientGetter
}
func NewK8s(helmConfig *action.Configuration, namespaces []string) (*K8s, error) {
factory := cmdutil.NewFactory(&cfgProxyObject{Impl: helmConfig.RESTClientGetter})
return &K8s{
Namespaces: namespaces,
Factory: factory,
RestClientGetter: factory,
}, nil
}
func (k *K8s) GetNameSpaces() (res *corev1.NamespaceList, err error) {
clientset, err := k.Factory.KubernetesClientSet()
if err != nil {
return nil, errors.Wrap(err, "failed to get KubernetesClientSet")
}
lst, err := clientset.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{})
if err != nil {
return nil, errors.Wrap(err, "failed to get list of namespaces")
}
if !slices.Contains(k.Namespaces, "") {
filtered := []corev1.Namespace{}
for _, ns := range lst.Items {
if slices.Contains(k.Namespaces, ns.Name) {
filtered = append(filtered, ns)
}
}
lst.Items = filtered
}
return lst, nil
}
func (k *K8s) DescribeResource(kind string, ns string, name string) (string, error) {
log.Debugf("Describing resource: %s %s in %s", kind, name, ns)
streams, _, out, errout := genericclioptions.NewTestIOStreams()
o := &describecmd.DescribeOptions{
Describer: func(mapping *meta.RESTMapping) (describe.ResourceDescriber, error) {
return describe.DescriberFn(k.RestClientGetter, mapping)
},
FilenameOptions: &resource.FilenameOptions{},
DescriberSettings: &describe.DescriberSettings{
ShowEvents: true,
ChunkSize: cmdutil.DefaultChunkSize,
},
IOStreams: streams,
NewBuilder: k.Factory.NewBuilder,
}
o.Namespace = ns
o.BuilderArgs = []string{kind, name}
err := o.Run()
if err != nil {
return "", errorx.Decorate(err, "Failed to run describe command: %s", errout.String())
}
return out.String(), nil
}
func (k *K8s) GetResource(kind string, namespace string, name string) (*runtime.Object, error) {
builder := k.Factory.NewBuilder()
resp := builder.Unstructured().NamespaceParam(namespace).Flatten().ResourceNames(kind, name).Do()
if resp.Err() != nil {
return nil, errorx.Decorate(resp.Err(), "failed to get k8s resource")
}
obj, err := resp.Object()
if err != nil {
return nil, errorx.Decorate(err, "failed to get k8s resulting object")
}
return &obj, nil
}
func (k *K8s) GetResourceInfo(kind string, namespace string, name string) (*testapiv1.Carp, error) {
obj, err := k.GetResource(kind, namespace, name)
if err != nil {
return nil, errorx.Decorate(err, "failed to get k8s object")
}
data, err := json.Marshal(obj)
if err != nil {
return nil, errorx.Decorate(err, "failed to marshal k8s object into JSON")
}
res := new(testapiv1.Carp)
err = json.Unmarshal(data, &res)
if err != nil {
return nil, errorx.Decorate(err, "failed to decode k8s object from JSON")
}
sort.Slice(res.Status.Conditions, func(i, j int) bool {
// some condition types always bubble up
if res.Status.Conditions[i].Type == "Available" {
return false
}
if res.Status.Conditions[j].Type == "Available" {
return true
}
t1 := res.Status.Conditions[i].LastTransitionTime
t2 := res.Status.Conditions[j].LastTransitionTime
return t1.Time.Before(t2.Time)
})
return res, nil
}
func (k *K8s) GetResourceYAML(kind string, namespace string, name string) (string, error) {
obj, err := k.GetResource(kind, namespace, name)
if err != nil {
return "", errorx.Decorate(err, "failed to get k8s object")
}
data, err := json.Marshal(obj)
if err != nil {
return "", errorx.Decorate(err, "failed to marshal k8s object into JSON")
}
res := map[string]interface{}{}
err = json.Unmarshal(data, &res)
if err != nil {
return "", errorx.Decorate(err, "failed to decode k8s object from JSON")
}
ydata, err := yaml.Marshal(res)
if err != nil {
return "", errorx.Decorate(err, "failed to marshal k8s object into JSON")
}
return string(ydata), nil
}

View File

@@ -0,0 +1,399 @@
package objects
import (
"bytes"
"fmt"
"gopkg.in/yaml.v3"
"io/ioutil"
"os"
"path"
"sync"
"github.com/joomcode/errorx"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/downloader"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/release"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
)
type Releases struct {
Namespaces []string
HelmConfig HelmNSConfigGetter
Settings *cli.EnvSettings
mx sync.Mutex
}
func (a *Releases) List() ([]*Release, error) {
a.mx.Lock()
defer a.mx.Unlock()
releases := []*Release{}
for _, ns := range a.Namespaces {
log.Debugf("Listing releases in namespace: %s", ns)
hc, err := a.HelmConfig(ns)
if err != nil {
return nil, errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
client := action.NewList(hc)
client.All = true
client.AllNamespaces = true
client.Limit = 0
rels, err := client.Run()
if err != nil {
return nil, errorx.Decorate(err, "failed to get list of releases")
}
for _, r := range rels {
releases = append(releases, &Release{HelmConfig: a.HelmConfig, Orig: r, Settings: a.Settings})
}
}
return releases, nil
}
func (a *Releases) ByName(namespace string, name string) (*Release, error) {
rels, err := a.List()
if err != nil {
return nil, errorx.Decorate(err, "failed to get list of releases")
}
for _, r := range rels {
if r.Orig.Namespace == namespace && r.Orig.Name == name {
return r, nil
}
}
return nil, errorx.DataUnavailable.New(fmt.Sprintf("release '%s' is not found in namespace '%s'", name, namespace))
}
func (a *Releases) Install(namespace string, name string, repoChart string, version string, justTemplate bool, values map[string]interface{}) (*release.Release, error) {
a.mx.Lock()
defer a.mx.Unlock()
if namespace == "" {
namespace = a.Settings.Namespace()
}
hc, err := a.HelmConfig(namespace)
if err != nil {
return nil, errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
cmd := action.NewInstall(hc)
cmd.ReleaseName = name
cmd.CreateNamespace = true
cmd.Namespace = namespace
cmd.Version = version
cmd.DryRun = justTemplate
chrt, err := locateChart(cmd.ChartPathOptions, repoChart, a.Settings)
if err != nil {
return nil, err
}
res, err := cmd.Run(chrt, values)
if err != nil {
return nil, err
}
if !justTemplate {
log.Infof("Installed new release: %s/%s", namespace, name)
}
return res, nil
}
func locateChart(pathOpts action.ChartPathOptions, chart string, settings *cli.EnvSettings) (*chart.Chart, error) {
// from cmd/helm/install.go and cmd/helm/upgrade.go
cp, err := pathOpts.LocateChart(chart, settings)
if err != nil {
return nil, err
}
log.Debugf("Located chart %s: %s\n", chart, cp)
p := getter.All(settings)
// Check chart dependencies to make sure all are present in /charts
chartRequested, err := loader.Load(cp)
if err != nil {
return nil, err
}
if err := checkIfInstallable(chartRequested); err != nil {
return nil, err
}
if req := chartRequested.Metadata.Dependencies; req != nil {
// If CheckDependencies returns an error, we have unfulfilled dependencies.
// As of Helm 2.4.0, this is treated as a stopping condition:
// https://github.com/helm/helm/issues/2209
if err := action.CheckDependencies(chartRequested, req); err != nil {
err = errorx.Decorate(err, "An error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies")
if true { // client.DependencyUpdate
man := &downloader.Manager{
Out: ioutil.Discard,
ChartPath: cp,
Keyring: pathOpts.Keyring,
SkipUpdate: false,
Getters: p,
RepositoryConfig: settings.RepositoryConfig,
RepositoryCache: settings.RepositoryCache,
Debug: settings.Debug,
}
if err := man.Update(); err != nil {
return nil, err
}
// Reload the chart with the updated Chart.lock file.
if chartRequested, err = loader.Load(cp); err != nil {
return nil, errorx.Decorate(err, "failed reloading chart after repo update")
}
} else {
return nil, err
}
}
}
return chartRequested, nil
}
type Release struct {
Settings *cli.EnvSettings
HelmConfig HelmNSConfigGetter
Orig *release.Release
revisions []*Release
mx sync.Mutex
restoredChartPath string
}
func (r *Release) History() ([]*Release, error) {
r.mx.Lock()
defer r.mx.Unlock()
hc, err := r.HelmConfig(r.Orig.Namespace)
if err != nil {
return nil, errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
client := action.NewHistory(hc)
revs, err := client.Run(r.Orig.Name)
if err != nil {
return nil, errorx.Decorate(err, "failed to get revisions of release")
}
r.revisions = []*Release{}
for _, rev := range revs {
r.revisions = append(r.revisions, &Release{HelmConfig: r.HelmConfig, Orig: rev, Settings: r.Settings})
}
return r.revisions, nil
}
func (r *Release) Uninstall() error {
r.mx.Lock()
defer r.mx.Unlock()
hc, err := r.HelmConfig(r.Orig.Namespace)
if err != nil {
return errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
client := action.NewUninstall(hc)
_, err = client.Run(r.Orig.Name)
if err != nil {
return errorx.Decorate(err, "failed to uninstall release")
}
return nil
}
func (r *Release) Rollback(toRevision int) error {
r.mx.Lock()
defer r.mx.Unlock()
hc, err := r.HelmConfig(r.Orig.Namespace)
if err != nil {
return errorx.Decorate(err, "failed to get helm config for namespace '%s'", "")
}
client := action.NewRollback(hc)
client.Version = toRevision
err = client.Run(r.Orig.Name)
if err != nil {
return errorx.Decorate(err, "failed to rollback the release")
}
log.Infof("Rolled back %s/%s to %d=>%d", r.Orig.Namespace, r.Orig.Name, r.Orig.Version, toRevision)
return nil
}
func (r *Release) RunTests() (string, error) {
r.mx.Lock()
defer r.mx.Unlock()
hc, err := r.HelmConfig(r.Orig.Namespace)
if err != nil {
return "", errorx.Decorate(err, "failed to get helm config for namespace '%s'", r.Orig.Namespace)
}
client := action.NewReleaseTesting(hc)
client.Namespace = r.Orig.Namespace
rel, err := client.Run(r.Orig.Name)
if err != nil {
return "", errorx.Decorate(err, "failed to execute 'helm test' for release '%s'", r.Orig.Name)
}
var buf bytes.Buffer
if err := client.GetPodLogs(&buf, rel); err != nil {
return "", errorx.Decorate(err, "failed to fetch logs for 'helm test' command")
}
return buf.String(), nil
}
func (r *Release) ParsedManifests() ([]*v1.Carp, error) {
carps, err := ParseManifests(r.Orig.Manifest)
if err != nil {
return nil, err
}
for _, carp := range carps {
if carp.Namespace == "" {
carp.Namespace = r.Orig.Namespace
}
}
return carps, err
}
func (r *Release) GetRev(revNo int) (*Release, error) {
if revNo == 0 {
revNo = r.Orig.Version
}
hist, err := r.History()
if err != nil {
return nil, errorx.Decorate(err, "failed to get history")
}
for _, rev := range hist {
if rev.Orig.Version == revNo {
return rev, nil
}
}
return nil, errorx.InternalError.New("No revision found for number %d", revNo)
}
func (r *Release) Upgrade(repoChart string, version string, justTemplate bool, values map[string]interface{}) (*release.Release, error) {
r.mx.Lock()
defer r.mx.Unlock()
// if repo chart is not passed, let's try to restore it from secret
if repoChart == "" {
var err error
repoChart, err = r.restoreChart()
if err != nil {
return nil, errorx.Decorate(err, "failed to revive chart for release")
}
}
ns := r.Settings.Namespace()
if r.Orig != nil {
ns = r.Orig.Namespace
}
hc, err := r.HelmConfig(ns)
if err != nil {
return nil, errorx.Decorate(err, "failed to get helm config for namespace '%s'", ns)
}
cmd := action.NewUpgrade(hc)
cmd.Namespace = r.Settings.Namespace()
cmd.Version = version
cmd.DryRun = justTemplate
chrt, err := locateChart(cmd.ChartPathOptions, repoChart, r.Settings)
if err != nil {
return nil, err
}
res, err := cmd.Run(r.Orig.Name, chrt, values)
if err != nil {
return nil, err
}
if !justTemplate {
log.Infof("Upgraded release: %s/%s#%d", res.Namespace, res.Name, res.Version)
}
return res, nil
}
func (r *Release) restoreChart() (string, error) {
if r.restoredChartPath != "" {
return r.restoredChartPath, nil
}
// we're unlikely to have the original chart, let's try the cheesy thing...
log.Infof("Attempting to restore the chart for %s", r.Orig.Name)
dir, err := ioutil.TempDir("", "khd-*")
if err != nil {
return "", errorx.Decorate(err, "failed to get temporary directory")
}
//restore Chart.yaml
cdata, err := yaml.Marshal(r.Orig.Chart.Metadata)
if err != nil {
return "", errorx.Decorate(err, "failed to restore Chart.yaml")
}
err = ioutil.WriteFile(path.Join(dir, "Chart.yaml"), cdata, 0644)
if err != nil {
return "", errorx.Decorate(err, "failed to write file Chart.yaml")
}
//restore known values
vdata, err := yaml.Marshal(r.Orig.Chart.Values)
if err != nil {
return "", errorx.Decorate(err, "failed to restore values.yaml")
}
err = ioutil.WriteFile(path.Join(dir, "values.yaml"), vdata, 0644)
if err != nil {
return "", errorx.Decorate(err, "failed to write file values.yaml")
}
// if possible, overwrite files with better alternatives
for _, f := range append(r.Orig.Chart.Raw, r.Orig.Chart.Templates...) {
fname := path.Join(dir, f.Name)
log.Debugf("Restoring file: %s", fname)
err := os.MkdirAll(path.Dir(fname), 0755)
if err != nil {
return "", errorx.Decorate(err, "failed to create directory for file: %s", fname)
}
err = ioutil.WriteFile(fname, f.Data, 0644)
if err != nil {
return "", errorx.Decorate(err, "failed to write file to restore chart: %s", fname)
}
}
r.restoredChartPath = dir
return dir, nil
}
func checkIfInstallable(ch *chart.Chart) error {
switch ch.Metadata.Type {
case "", "application":
return nil
}
return errors.Errorf("%s charts are not installable", ch.Metadata.Type)
}

View File

@@ -0,0 +1,80 @@
package objects
import (
"sync"
"testing"
"gotest.tools/v3/assert"
"helm.sh/helm/v3/pkg/action"
kubefake "helm.sh/helm/v3/pkg/kube/fake"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/storage"
"helm.sh/helm/v3/pkg/storage/driver"
)
var (
fakeKubeClient *kubefake.PrintingKubeClient
fakeStorage *storage.Storage
)
func fakeHelmNSConfigGetter(ns string) (*action.Configuration, error) {
return &action.Configuration{
KubeClient: fakeKubeClient,
Releases: fakeStorage,
}, nil
}
func TestListReleases(t *testing.T) {
fakeStorage = storage.Init(driver.NewMemory())
err := fakeStorage.Create(&release.Release{
Name: "release1",
Info: &release.Info{
Status: release.StatusDeployed,
},
})
assert.NilError(t, err)
err = fakeStorage.Create(&release.Release{
Name: "release2",
Info: &release.Info{
Status: release.StatusDeployed,
},
})
assert.NilError(t, err)
err = fakeStorage.Create(&release.Release{
Name: "release3",
Info: &release.Info{
Status: release.StatusDeployed,
},
})
assert.NilError(t, err)
err = fakeStorage.Create(&release.Release{
Name: "release4",
Info: &release.Info{
Status: release.StatusDeployed,
},
})
assert.NilError(t, err)
err = fakeStorage.Create(&release.Release{
Name: "release5",
Info: &release.Info{
Status: release.StatusDeployed,
},
})
assert.NilError(t, err)
releases := &Releases{
Namespaces: []string{"testNamespace"},
HelmConfig: fakeHelmNSConfigGetter,
mx: sync.Mutex{},
}
res, err := releases.List()
assert.NilError(t, err)
assert.Equal(t, len(res), 5)
assert.Equal(t, res[0].Orig.Name, "release1")
assert.Equal(t, res[1].Orig.Name, "release2")
assert.Equal(t, res[2].Orig.Name, "release3")
assert.Equal(t, res[3].Orig.Name, "release4")
assert.Equal(t, res[4].Orig.Name, "release5")
}

View File

@@ -0,0 +1,312 @@
package objects
import (
"github.com/joomcode/errorx"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/helmpath"
"helm.sh/helm/v3/pkg/repo"
"os"
"path/filepath"
"strings"
"sync"
)
const AnnRepo = "helm-dashboard/repository-name"
type Repositories struct {
Settings *cli.EnvSettings
HelmConfig *action.Configuration
mx sync.Mutex
}
func (r *Repositories) Load() (*repo.File, error) {
r.mx.Lock()
defer r.mx.Unlock()
// copied from cmd/helm/repo_list.go
f, err := repo.LoadFile(r.Settings.RepositoryConfig)
if err != nil && !isNotExist(err) {
return nil, errorx.Decorate(err, "failed to load repository list")
}
return f, nil
}
func (r *Repositories) List() ([]*Repository, error) {
f, err := r.Load()
if err != nil {
return nil, errorx.Decorate(err, "failed to load repo information")
}
res := []*Repository{}
for _, item := range f.Repositories {
res = append(res, &Repository{
Settings: r.Settings,
Orig: item,
})
}
return res, nil
}
func (r *Repositories) Add(name string, url string) error {
if name == "" || url == "" {
return errors.New("Name and URL are required parameters to add the repository")
}
// copied from cmd/helm/repo_add.go
repoFile := r.Settings.RepositoryConfig
// Ensure the file directory exists as it is required for file locking
err := os.MkdirAll(filepath.Dir(repoFile), os.ModePerm)
if err != nil && !os.IsExist(err) {
return err
}
f, err := r.Load()
if err != nil {
return errorx.Decorate(err, "Failed to load repo config")
}
r.mx.Lock()
defer r.mx.Unlock()
c := repo.Entry{
Name: name,
URL: url,
//Username: o.username,
//Password: o.password,
//PassCredentialsAll: o.passCredentialsAll,
//CertFile: o.certFile,
//KeyFile: o.keyFile,
//CAFile: o.caFile,
//InsecureSkipTLSverify: o.insecureSkipTLSverify,
}
// Check if the repo name is legal
if strings.Contains(c.Name, "/") {
return errors.Errorf("repository name (%s) contains '/', please specify a different name without '/'", c.Name)
}
rep, err := repo.NewChartRepository(&c, getter.All(r.Settings))
if err != nil {
return err
}
if _, err := rep.DownloadIndexFile(); err != nil {
return errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", url)
}
f.Update(&c)
if err := f.WriteFile(repoFile, 0644); err != nil {
return err
}
return nil
}
func (r *Repositories) Delete(name string) error {
f, err := r.Load()
if err != nil {
return errorx.Decorate(err, "failed to load repo information")
}
r.mx.Lock()
defer r.mx.Unlock()
// copied from cmd/helm/repo_remove.go
if !f.Remove(name) {
return errors.Errorf("no repo named %q found", name)
}
if err := f.WriteFile(r.Settings.RepositoryConfig, 0644); err != nil {
return err
}
if err := removeRepoCache(r.Settings.RepositoryCache, name); err != nil {
return err
}
return nil
}
func (r *Repositories) Get(name string) (*Repository, error) {
f, err := r.Load()
if err != nil {
return nil, errorx.Decorate(err, "failed to load repo information")
}
for _, entry := range f.Repositories {
if entry.Name == name {
return &Repository{
Settings: r.Settings,
Orig: entry,
}, nil
}
}
return nil, errorx.DataUnavailable.New("Could not find reposiroty '%s'", name)
}
func (r *Repositories) Containing(name string) (repo.ChartVersions, error) {
list, err := r.List()
if err != nil {
return nil, errorx.Decorate(err, "failed to get list of repos")
}
res := repo.ChartVersions{}
for _, rep := range list {
vers, err := rep.ByName(name)
if err != nil {
log.Warnf("Failed to get data from repo '%s', updating it might help", rep.Orig.Name)
log.Debugf("The error was: %v", err)
continue
}
for _, v := range vers {
// just using annotations here to attach a bit of information to the object
// it has nothing to do with k8s annotations and should not get into manifests
if v.Annotations == nil {
v.Annotations = map[string]string{}
}
v.Annotations[AnnRepo] = rep.Orig.Name
}
res = append(res, vers...) // TODO filter dev versions here, relates to #139
}
return res, nil
}
func (r *Repositories) GetChart(chart string, ver string) (*chart.Chart, error) {
// TODO: unused method?
client := action.NewShowWithConfig(action.ShowAll, r.HelmConfig)
client.Version = ver
cp, err := client.ChartPathOptions.LocateChart(chart, r.Settings)
if err != nil {
return nil, errorx.Decorate(err, "failed to locate chart '%s'", chart)
}
chrt, err := loader.Load(cp)
if err != nil {
return nil, errorx.Decorate(err, "failed to load chart from '%s'", cp)
}
return chrt, nil
}
func (r *Repositories) GetChartValues(chart string, ver string) (string, error) {
// comes from cmd/helm/show.go
client := action.NewShowWithConfig(action.ShowValues, r.HelmConfig)
client.Version = ver
cp, err := client.ChartPathOptions.LocateChart(chart, r.Settings)
if err != nil {
return "", err
}
out, err := client.Run(cp)
if err != nil {
return "", errorx.Decorate(err, "failed to get values for chart '%s'", chart)
}
return out, nil
}
type Repository struct {
Settings *cli.EnvSettings
Orig *repo.Entry
mx sync.Mutex
}
func (r *Repository) indexFileName() string {
return filepath.Join(r.Settings.RepositoryCache, helmpath.CacheIndexFile(r.Orig.Name))
}
func (r *Repository) getIndex() (*repo.IndexFile, error) {
r.mx.Lock()
defer r.mx.Unlock()
f := r.indexFileName()
ind, err := repo.LoadIndexFile(f)
if err != nil {
return nil, errorx.Decorate(err, "Repo index is corrupt or missing. Try updating repo")
}
ind.SortEntries()
return ind, nil
}
func (r *Repository) Charts() ([]*repo.ChartVersion, error) {
ind, err := r.getIndex()
if err != nil {
return nil, errorx.Decorate(err, "failed to get repo index")
}
res := []*repo.ChartVersion{}
for _, v := range ind.Entries {
if len(v) > 0 { // TODO filter dev versions here, relates to #139
res = append(res, v[0])
}
}
return res, nil
}
func (r *Repository) ByName(name string) (repo.ChartVersions, error) {
ind, err := r.getIndex()
if err != nil {
return nil, errorx.Decorate(err, "failed to get repo index")
}
nx, ok := ind.Entries[name]
if ok {
return nx, nil
}
return repo.ChartVersions{}, nil
}
func (r *Repository) Update() error {
r.mx.Lock()
defer r.mx.Unlock()
log.Infof("Updating repository: %s", r.Orig.Name)
// from cmd/helm/repo_update.go
// TODO: make this object to be an `Orig`?
rep, err := repo.NewChartRepository(r.Orig, getter.All(r.Settings))
if err != nil {
return errorx.Decorate(err, "could not create repository object")
}
rep.CachePath = r.Settings.RepositoryCache
_, err = rep.DownloadIndexFile()
if err != nil {
return errorx.Decorate(err, "failed to download repo index file")
}
return nil
}
// copied from cmd/helm/repo.go
func isNotExist(err error) bool {
return os.IsNotExist(errors.Cause(err))
}
// copied from cmd/helm/repo_remove.go
func removeRepoCache(root, name string) error {
idx := filepath.Join(root, helmpath.CacheChartsFile(name))
if _, err := os.Stat(idx); err == nil {
_ = os.Remove(idx)
}
idx = filepath.Join(root, helmpath.CacheIndexFile(name))
if _, err := os.Stat(idx); os.IsNotExist(err) {
return nil
} else if err != nil {
return errors.Wrapf(err, "can't remove index file %s", idx)
}
return os.Remove(idx)
}

View File

@@ -0,0 +1,149 @@
package objects
import (
"helm.sh/helm/v3/pkg/action"
"testing"
"gotest.tools/v3/assert"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/repo"
)
var filePath = "./testdata/repositories.yaml"
func initRepository(t *testing.T, filePath string) *Repositories {
t.Helper()
settings := cli.New()
// Sets the repository file path
settings.RepositoryConfig = filePath
testRepository := &Repositories{
Settings: settings,
HelmConfig: &action.Configuration{}, // maybe use copy of getFakeHelmConfig from api_test.go
}
return testRepository
}
func TestLoadRepo(t *testing.T) {
res, err := repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
testRepository := initRepository(t, filePath)
file, err := testRepository.Load()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, file.Generated, res.Generated)
}
func TestList(t *testing.T) {
res, err := repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
testRepository := initRepository(t, filePath)
repos, err := testRepository.List()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(repos), len(res.Repositories))
}
func TestAdd(t *testing.T) {
testRepoName := "TEST"
testRepoUrl := "https://helm.github.io/examples"
res, err := repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
// Delete the repository if already exist
res.Remove(testRepoName)
testRepository := initRepository(t, filePath)
err = testRepository.Add(testRepoName, testRepoUrl)
if err != nil {
t.Fatal(err, "Failed to add repo")
}
// Reload the file
res, err = repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, res.Has(testRepoName), true)
// Removes test repository which is added for testing
t.Cleanup(func() {
removed := res.Remove(testRepoName)
if removed != true {
t.Log("Failed to clean the test repository file")
}
err = res.WriteFile(filePath, 0644)
if err != nil {
t.Log("Failed to write the file while cleaning test repo")
}
})
}
func TestDelete(t *testing.T) {
testRepoName := "TEST DELETE"
testRepoUrl := "https://helm.github.io/examples"
res, err := repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
// Add a test entry
res.Add(&repo.Entry{Name: testRepoName, URL: testRepoUrl})
err = res.WriteFile(filePath, 0644)
if err != nil {
t.Fatal("Failed to write the file while creating test repo")
}
testRepository := initRepository(t, filePath)
err = testRepository.Delete(testRepoName)
if err != nil {
t.Fatal(err, "Failed to delete the repo")
}
// Reload the file
res, err = repo.LoadFile(filePath)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, res.Has(testRepoName), false)
}
func TestGet(t *testing.T) {
// Initial repositiry name in test file
repoName := "charts"
testRepository := initRepository(t, filePath)
repo, err := testRepository.Get(repoName)
if err != nil {
t.Fatal(err, "Failed to get th repo")
}
assert.Equal(t, repo.Orig.Name, repoName)
}

View File

@@ -0,0 +1,30 @@
apiVersion: ""
generated: "0001-01-01T00:00:00Z"
repositories:
- caFile: ""
certFile: ""
insecure_skip_tls_verify: false
keyFile: ""
name: charts
pass_credentials_all: false
password: ""
url: https://charts.helm.sh/stable
username: ""
- caFile: ""
certFile: ""
insecure_skip_tls_verify: false
keyFile: ""
name: firstexample
pass_credentials_all: false
password: ""
url: http://firstexample.com
username: ""
- caFile: ""
certFile: ""
insecure_skip_tls_verify: false
keyFile: ""
name: secondexample
pass_credentials_all: false
password: ""
url: http://secondexample.com
username: ""

View File

@@ -2,6 +2,8 @@ package scanners
import ( import (
"encoding/json" "encoding/json"
"github.com/joomcode/errorx"
"github.com/komodorio/helm-dashboard/pkg/dashboard/objects"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc" "github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils" "github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
"github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter"
@@ -11,7 +13,7 @@ import (
) )
type Checkov struct { type Checkov struct {
Data *subproc.DataLayer Data *objects.DataLayer
} }
func (c *Checkov) ManifestIsScannable() bool { func (c *Checkov) ManifestIsScannable() bool {
@@ -77,7 +79,7 @@ func (c *Checkov) ScanManifests(mnf string) (*subproc.ScanResults, error) {
res := &subproc.ScanResults{} res := &subproc.ScanResults{}
err = json.Unmarshal([]byte(out), res.OrigReport) err = json.Unmarshal([]byte(out), &res.OrigReport)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -89,14 +91,19 @@ func (c *Checkov) ScanResource(ns string, kind string, name string) (*subproc.Sc
carp := v1.Carp{} carp := v1.Carp{}
carp.Kind = kind carp.Kind = kind
carp.Name = name carp.Name = name
mnf, err := c.Data.GetResourceYAML(ns, &carp) app, err := c.Data.AppForCtx(c.Data.KubeContext)
if err != nil { if err != nil {
return nil, err return nil, errorx.Decorate(err, "failed to get app for context")
}
mnf, err := app.K8s.GetResourceYAML(kind, ns, name)
if err != nil {
return nil, errorx.Decorate(err, "failed to get YAML for resource")
} }
fname, fclose, err := utils.TempFile(mnf) fname, fclose, err := utils.TempFile(mnf)
if err != nil { if err != nil {
return nil, err return nil, errorx.Decorate(err, "failed to create temporary file")
} }
defer fclose() defer fclose()

View File

@@ -1,6 +1,7 @@
package scanners package scanners
import ( import (
"github.com/komodorio/helm-dashboard/pkg/dashboard/objects"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc" "github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils" "github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -9,7 +10,7 @@ import (
) )
type Trivy struct { type Trivy struct {
Data *subproc.DataLayer Data *objects.DataLayer
} }
func (c *Trivy) ManifestIsScannable() bool { func (c *Trivy) ManifestIsScannable() bool {

View File

@@ -4,6 +4,12 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/joomcode/errorx"
"github.com/komodorio/helm-dashboard/pkg/dashboard/objects"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/registry"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@@ -12,54 +18,87 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hashicorp/go-version" "github.com/hashicorp/go-version"
"github.com/komodorio/helm-dashboard/pkg/dashboard/scanners" "github.com/komodorio/helm-dashboard/pkg/dashboard/scanners"
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils" "github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
type Server struct { type Server struct {
Version string Version string
Namespace string Namespaces []string
Address string Address string
Debug bool Debug bool
NoTracking bool NoTracking bool
} }
func (s Server) StartServer() (string, utils.ControlChan) { func (s *Server) StartServer(ctx context.Context, cancel context.CancelFunc) (string, utils.ControlChan, error) {
data := subproc.DataLayer{ data, err := objects.NewDataLayer(s.Namespaces, s.Version, NewHelmConfig)
Namespace: s.Namespace,
Cache: subproc.NewCache(),
StatusInfo: &subproc.StatusInfo{
CurVer: s.Version,
Analytics: false,
LimitedToNamespace: s.Namespace,
},
}
err := data.CheckConnectivity()
if err != nil { if err != nil {
log.Errorf("Failed to check that Helm is operational, cannot continue. The error was: %s", err) return "", nil, errorx.Decorate(err, "Failed to create data layer")
os.Exit(1) // TODO: propagate error instead?
} }
isDevModeWithAnalytics := os.Getenv("HD_DEV_ANALYTICS") == "true" isDevModeWithAnalytics := os.Getenv("HD_DEV_ANALYTICS") == "true"
data.StatusInfo.Analytics = (!s.NoTracking && s.Version != "0.0.0") || isDevModeWithAnalytics data.StatusInfo.Analytics = (!s.NoTracking && s.Version != "0.0.0") || isDevModeWithAnalytics
go checkUpgrade(data.StatusInfo)
discoverScanners(&data) err = s.detectClusterMode(data)
if err != nil {
abort := make(utils.ControlChan) return "", nil, err
api := NewRouter(abort, &data, s.Debug)
done := s.startBackgroundServer(api, abort)
return "http://" + s.Address, done
} }
func (s Server) startBackgroundServer(routes *gin.Engine, abort utils.ControlChan) utils.ControlChan { go checkUpgrade(data.StatusInfo)
discoverScanners(data)
go data.PeriodicTasks(ctx)
api := NewRouter(cancel, data, s.Debug)
done := s.startBackgroundServer(api, ctx)
return "http://" + s.Address, done, nil
}
func (s *Server) detectClusterMode(data *objects.DataLayer) error {
data.StatusInfo.ClusterMode = os.Getenv("HD_CLUSTER_MODE") != ""
if data.StatusInfo.ClusterMode {
return nil
}
ctxs, err := data.ListContexts()
if err != nil {
return err
}
if len(ctxs) == 0 {
log.Infof("Got no kubectl config contexts, will attempt to detect if we're inside cluster...")
app, err := data.AppForCtx("")
if err != nil {
return err
}
ns, err := app.K8s.GetNameSpaces()
if err != nil { // no point in continuing without kubectl context and k8s connection
return err
}
log.Debugf("Got %d namespaces listed", len(ns.Items))
data.StatusInfo.ClusterMode = true
}
return err
}
func (s *Server) startBackgroundServer(routes *gin.Engine, ctx context.Context) utils.ControlChan {
done := make(utils.ControlChan) done := make(utils.ControlChan)
server := &http.Server{ server := &http.Server{
Addr: s.Address, Addr: s.Address,
Handler: routes, Handler: routes,
} }
go func() {
<-ctx.Done()
err := server.Shutdown(context.Background())
if err != nil {
log.Warnf("Had problems shutting down the server: %s", err)
}
log.Infof("Web server has been shut down.")
}()
go func() { go func() {
err := server.ListenAndServe() err := server.ListenAndServe()
if err != nil && err != http.ErrServerClosed { if err != nil && err != http.ErrServerClosed {
@@ -73,18 +112,10 @@ func (s Server) startBackgroundServer(routes *gin.Engine, abort utils.ControlCha
done <- struct{}{} done <- struct{}{}
}() }()
go func() {
<-abort
err := server.Shutdown(context.Background())
if err != nil {
log.Warnf("Had problems shutting down the server: %s", err)
}
}()
return done return done
} }
func (s Server) itIsUs() bool { func (s *Server) itIsUs() bool {
url := fmt.Sprintf("http://%s/status", s.Address) url := fmt.Sprintf("http://%s/status", s.Address)
var myClient = &http.Client{ var myClient = &http.Client{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
@@ -99,7 +130,7 @@ func (s Server) itIsUs() bool {
return strings.HasPrefix(r.Header.Get("X-Application-Name"), "Helm Dashboard") return strings.HasPrefix(r.Header.Get("X-Application-Name"), "Helm Dashboard")
} }
func discoverScanners(data *subproc.DataLayer) { func discoverScanners(data *objects.DataLayer) {
potential := []subproc.Scanner{ potential := []subproc.Scanner{
&scanners.Checkov{Data: data}, &scanners.Checkov{Data: data},
&scanners.Trivy{Data: data}, &scanners.Trivy{Data: data},
@@ -113,7 +144,7 @@ func discoverScanners(data *subproc.DataLayer) {
} }
} }
func checkUpgrade(d *subproc.StatusInfo) { // TODO: check it once an hour func checkUpgrade(d *objects.StatusInfo) { // TODO: check it once an hour
url := "https://api.github.com/repos/komodorio/helm-dashboard/releases/latest" url := "https://api.github.com/repos/komodorio/helm-dashboard/releases/latest"
type GHRelease struct { type GHRelease struct {
Name string `json:"name"` Name string `json:"name"`
@@ -143,7 +174,7 @@ func checkUpgrade(d *subproc.StatusInfo) { // TODO: check it once an hour
v2, err := version.NewVersion(d.LatestVer) v2, err := version.NewVersion(d.LatestVer)
if err != nil { if err != nil {
log.Warnf("Failed to parse LatestVer: %s", err) log.Warnf("Failed to parse RepoLatestVer: %s", err)
} else { } else {
if v1.LessThan(v2) { if v1.LessThan(v2) {
log.Warnf("Newer Helm Dashboard version is available: %s", d.LatestVer) log.Warnf("Newer Helm Dashboard version is available: %s", d.LatestVer)
@@ -153,3 +184,34 @@ func checkUpgrade(d *subproc.StatusInfo) { // TODO: check it once an hour
} }
} }
} }
func NewHelmConfig(origSettings *cli.EnvSettings, ns string) (*action.Configuration, error) {
// TODO: cache it into map
// TODO: I feel there should be more elegant way to organize this code
actionConfig := new(action.Configuration)
settings := cli.New()
settings.KubeContext = origSettings.KubeContext
settings.SetNamespace(ns) // important for RESTClientGetter to have correct namespace
registryClient, err := registry.NewClient(
registry.ClientOptDebug(false),
registry.ClientOptEnableCache(true),
//registry.ClientOptWriter(out),
registry.ClientOptCredentialsFile(settings.RegistryConfig),
)
if err != nil {
return nil, errorx.Decorate(err, "failed to crete helm config object")
}
actionConfig.RegistryClient = registryClient
helmDriver := os.Getenv("HELM_DRIVER")
if err := actionConfig.Init(
settings.RESTClientGetter(),
ns,
helmDriver, log.Debugf); err != nil {
return nil, errorx.Decorate(err, "failed to init Helm action config")
}
return actionConfig, nil
}

View File

@@ -5,7 +5,7 @@ $("#btnUpgradeCheck").click(function () {
const repoName = self.data("repo") const repoName = self.data("repo")
$("#btnUpgrade span").text("Checking...") $("#btnUpgrade span").text("Checking...")
$("#btnUpgrade .icon").removeClass("bi-arrow-up bi-pencil").addClass("bi-hourglass-split") $("#btnUpgrade .icon").removeClass("bi-arrow-up bi-pencil").addClass("bi-hourglass-split")
$.post("/api/helm/repo/update?name=" + repoName).fail(function (xhr) { $.post("/api/helm/repositories/" + repoName).fail(function (xhr) {
reportError("Failed to update chart repo", xhr) reportError("Failed to update chart repo", xhr)
}).done(function () { }).done(function () {
self.find(".spinner-border").hide() self.find(".spinner-border").hide()
@@ -16,31 +16,29 @@ $("#btnUpgradeCheck").click(function () {
}) })
}) })
function checkUpgradeable(name) { function checkUpgradeable(name) {
$.getJSON("/api/helm/repo/search?name=" + name).fail(function (xhr) { $.getJSON("/api/helm/repositories/latestver?name=" + name).fail(function (xhr) {
reportError("Failed to find chart in repo", xhr) reportError("Failed to find chart in repo", xhr)
}).done(function (data) { }).done(function (data) {
let elm = {name: "", version: "0"}
const btnUpgradeCheck = $("#btnUpgradeCheck");
if (!data || !data.length) { if (!data || !data.length) {
$("#btnUpgrade span").text("No upgrades") btnUpgradeCheck.prop("disabled", true)
$("#btnUpgrade .icon").removeClass("bi-hourglass-split").addClass("bi-x-octagon") btnUpgradeCheck.text("")
$("#btnUpgrade").prop("disabled", true)
$("#btnUpgradeCheck").prop("disabled", true)
$("#btnAddRepository").text("Add repository for it") $("#btnAddRepository").text("Add repository for it")
$("#btnUpgradeCheck").text("") } else {
return $("#btnAddRepository").text("")
btnUpgradeCheck.text("Check for new version")
elm = data[0]
} }
$("#btnUpgrade .icon").removeClass("bi-x-octagon").addClass("bi-hourglass-split") $("#btnUpgrade .icon").removeClass("bi-arrow-up bi-pencil").addClass("bi-hourglass-split")
$("#btnAddRepository").text("")
$("#btnUpgradeCheck").text("Check for new version")
const verCur = $("#specRev").data("last-chart-ver"); const verCur = $("#specRev").data("last-chart-ver");
const elm = data[0] btnUpgradeCheck.data("repo", elm.repository)
$("#btnUpgradeCheck").data("repo", elm.name.split('/').shift()) btnUpgradeCheck.data("chart", elm.name)
$("#btnUpgradeCheck").data("chart", elm.name.split('/').pop())
const canUpgrade = isNewerVersion(verCur, elm.version); const canUpgrade = isNewerVersion(verCur, elm.version);
$("#btnUpgradeCheck").prop("disabled", false) btnUpgradeCheck.prop("disabled", false)
if (canUpgrade) { if (canUpgrade) {
$("#btnUpgrade span").text("Upgrade to " + elm.version) $("#btnUpgrade span").text("Upgrade to " + elm.version)
$("#btnUpgrade .icon").removeClass("bi-hourglass-split").addClass("bi-arrow-up") $("#btnUpgrade .icon").removeClass("bi-hourglass-split").addClass("bi-arrow-up")
@@ -58,7 +56,14 @@ function checkUpgradeable(name) {
function popUpUpgrade(elm, ns, name, verCur, lastRev) { function popUpUpgrade(elm, ns, name, verCur, lastRev) {
$("#upgradeModal .btn-confirm").prop("disabled", true) $("#upgradeModal .btn-confirm").prop("disabled", true)
$('#upgradeModal').data("chart", elm.name).data("initial", !verCur) let chart = elm.repository + "/" + elm.name;
if (!elm.name) {
chart = ""
}
$('#upgradeModal').data("chart", chart).data("initial", !verCur)
$('#upgradeModal form .chart-name').val(chart)
$('#upgradeModal').data("newManifest", "")
$("#upgradeModalLabel .name").text(elm.name) $("#upgradeModalLabel .name").text(elm.name)
@@ -69,14 +74,23 @@ function popUpUpgrade(elm, ns, name, verCur, lastRev) {
$("#upgradeModal .ver-old").show().find("span").text(verCur) $("#upgradeModal .ver-old").show().find("span").text(verCur)
$("#upgradeModal .rel-name").prop("disabled", true).val(name) $("#upgradeModal .rel-name").prop("disabled", true).val(name)
$("#upgradeModal .rel-ns").prop("disabled", true).val(ns) $("#upgradeModal .rel-ns").prop("disabled", true).val(ns)
$.get("/api/helm/releases/" + ns + "/" + name + "/manifests").fail(function (xhr) {
reportError("Failed to get current manifest", xhr)
}).done(function (text) {
$('#upgradeModal').data("curManifest", text)
})
} else { } else {
$("#upgradeModalLabel .type").text("Install") $("#upgradeModalLabel .type").text("Install")
$("#upgradeModal .ver-old").hide() $("#upgradeModal .ver-old").hide()
$("#upgradeModal .rel-name").prop("disabled", false).val(elm.name.split("/").pop()) $("#upgradeModal .rel-name").prop("disabled", false).val(elm.name.split("/").pop())
$("#upgradeModal .rel-ns").prop("disabled", false).val(ns) $("#upgradeModal .rel-ns").prop("disabled", false).val(ns)
$('#upgradeModal').data("curManifest", "")
} }
$.getJSON("/api/helm/repo/search?name=" + elm.name).fail(function (xhr) { if (elm.name) {
$.getJSON("/api/helm/repositories/versions?name=" + elm.name).fail(function (xhr) {
reportError("Failed to find chart in repo", xhr) reportError("Failed to find chart in repo", xhr)
}).done(function (vers) { }).done(function (vers) {
// fill versions // fill versions
@@ -91,14 +105,22 @@ function popUpUpgrade(elm, ns, name, verCur, lastRev) {
$('#upgradeModal select').append(opt) $('#upgradeModal select').append(opt)
} }
$('#upgradeModal select').val(elm.version).trigger("change") $('#upgradeModal select').val(elm.version).trigger("change").parent().show()
upgrPopUpCommon(verCur, ns, lastRev, name)
})
} else { // chart without repo reconfigure
$('#upgradeModal select').empty().trigger("change").parent().hide()
upgrPopUpCommon(verCur, ns, lastRev, name)
}
}
function upgrPopUpCommon(verCur, ns, lastRev, name) {
const myModal = new bootstrap.Modal(document.getElementById('upgradeModal'), {}); const myModal = new bootstrap.Modal(document.getElementById('upgradeModal'), {});
myModal.show() myModal.show()
if (verCur) { if (verCur) {
// fill current values // fill current values
$.get("/api/helm/charts/values?namespace=" + ns + "&revision=" + lastRev + "&name=" + name + "&flag=true").fail(function (xhr) { $.get("/api/helm/releases/" + ns + "/" + name + "/values?userDefined=true&revision=" + lastRev).fail(function (xhr) {
reportError("Failed to get charts values info", xhr) reportError("Failed to get charts values info", xhr)
}).done(function (data) { }).done(function (data) {
$("#upgradeModal textarea").val(data).data("dirty", false) $("#upgradeModal textarea").val(data).data("dirty", false)
@@ -106,16 +128,16 @@ function popUpUpgrade(elm, ns, name, verCur, lastRev) {
} else { } else {
$("#upgradeModal textarea").val("").data("dirty", true) $("#upgradeModal textarea").val("").data("dirty", true)
} }
})
} }
$("#upgradeModal .btn-confirm").click(function () { $("#upgradeModal .btn-confirm").click(function () {
const btnConfirm = $("#upgradeModal .btn-confirm") const btnConfirm = $("#upgradeModal .btn-confirm")
btnConfirm.prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') btnConfirm.prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$('#upgradeModal form .preview-mode').val("false")
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: "/api/helm/charts/install" + upgradeModalQstr() + "&flag=true", url: upgradeModalURL(),
data: $("#upgradeModal textarea").data("dirty") ? $("#upgradeModal form").serialize() : null, data: $("#upgradeModal form").serialize(),
}).fail(function (xhr) { }).fail(function (xhr) {
reportError("Failed to upgrade the chart", xhr) reportError("Failed to upgrade the chart", xhr)
}).done(function (data) { }).done(function (data) {
@@ -156,22 +178,33 @@ $('#upgradeModal select').change(function () {
// fill reference values // fill reference values
$("#upgradeModal .ref-vals").html('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') $("#upgradeModal .ref-vals").html('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$.get("/api/helm/repo/values?chart=" + $("#upgradeModal").data("chart") + "&version=" + self.val()).fail(function (xhr) { const chart = $("#upgradeModal").data("chart");
// TODO: if chart is empty, query different URL that will restore values without repo
if (chart) {
$.get("/api/helm/repositories/values?chart=" + chart + "&version=" + self.val()).fail(function (xhr) {
reportError("Failed to get upgrade info", xhr) reportError("Failed to get upgrade info", xhr)
}).done(function (data) { }).done(function (data) {
data = hljs.highlight(data, {language: 'yaml'}).value data = hljs.highlight(data, {language: 'yaml'}).value
$("#upgradeModal .ref-vals").html(data) $("#upgradeModal .ref-vals").html(data)
}) })
} else {
$("#upgradeModal .ref-vals").html("No original values information found")
}
}) })
$('#upgradeModal .btn-scan').click(function () { $('#upgradeModal .btn-scan').click(function () {
const self = $(this) const self = $(this)
self.prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') self.prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
const form = new FormData();
form.append('manifest', $('#upgradeModal').data("newManifest"));
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: "/api/scanners/manifests" + upgradeModalQstr(), url: "/api/scanners/manifests",
data: $("#upgradeModal form").serialize(), processData: false,
contentType: false,
data: form,
}).fail(function (xhr) { }).fail(function (xhr) {
reportError("Failed to scan the manifest", xhr) reportError("Failed to scan the manifest", xhr)
}).done(function (data) { }).done(function (data) {
@@ -185,7 +218,7 @@ $('#upgradeModal .btn-scan').click(function () {
continue continue
} }
const pre = $("<pre></pre>").text(res.OrigReport) const pre = $("<pre></pre>").text(JSON.stringify(res.OrigReport, null, 2))
container.append("<h2>" + name + " Scan Results</h2>") container.append("<h2>" + name + " Scan Results</h2>")
container.append(pre) container.append(pre)
@@ -203,10 +236,10 @@ function requestChangeDiff() {
diffBody.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Calculating diff...') diffBody.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Calculating diff...')
$("#upgradeModal .btn-confirm").prop("disabled", true) $("#upgradeModal .btn-confirm").prop("disabled", true)
let values = null; $('#upgradeModal form .preview-mode').val("true")
let form = $("#upgradeModal form").serialize();
if ($("#upgradeModal textarea").data("dirty")) { if ($("#upgradeModal textarea").data("dirty")) {
$("#upgradeModal .invalid-feedback").hide() $("#upgradeModal .invalid-feedback").hide()
values = $("#upgradeModal form").serialize()
try { try {
jsyaml.load($("#upgradeModal textarea").val()) jsyaml.load($("#upgradeModal textarea").val())
@@ -219,8 +252,23 @@ function requestChangeDiff() {
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: "/api/helm/charts/install" + upgradeModalQstr(), url: upgradeModalURL(),
data: values, data: form,
}).fail(function (xhr) {
$("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: " + xhr.responseText + "</p>")
}).done(function (data) {
$('#upgradeModal').data("newManifest", data.manifest)
const form = new FormData();
form.append('a', $('#upgradeModal').data("curManifest"));
form.append('b', data.manifest);
$.ajax({
type: "POST",
url: "/diff",
processData: false,
contentType: false,
data: form,
}).fail(function (xhr) { }).fail(function (xhr) {
$("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: " + xhr.responseText + "</p>") $("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: " + xhr.responseText + "</p>")
}).done(function (data) { }).done(function (data) {
@@ -238,17 +286,18 @@ function requestChangeDiff() {
diffBody.html("No changes will happen to the cluster") diffBody.html("No changes will happen to the cluster")
} }
}) })
})
} }
function upgradeModalQstr() { function upgradeModalURL() {
let qstr = "?" + let ns = $("#upgradeModal .rel-ns").val();
"namespace=" + $("#upgradeModal .rel-ns").val() + if (!ns) {
"&name=" + $("#upgradeModal .rel-name").val() + ns = "[empty]"
"&chart=" + $("#upgradeModal").data("chart") + }
"&version=" + $('#upgradeModal select').val()
if ($("#upgradeModal").data("initial")) { let qstr = "/api/helm/releases/" + ns;
qstr += "&initial=true" if (!$("#upgradeModal").data("initial")) {
qstr += "/" + $("#upgradeModal .rel-name").val()
} }
return qstr return qstr
@@ -263,7 +312,7 @@ $("#btnUninstall").click(function () {
$("#confirmModalBody").empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') $("#confirmModalBody").empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
btnConfirm.prop("disabled", true).off('click').click(function () { btnConfirm.prop("disabled", true).off('click').click(function () {
btnConfirm.prop("disabled", true).append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') btnConfirm.prop("disabled", true).append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
const url = "/api/helm/charts?namespace=" + namespace + "&name=" + chart; const url = "/api/helm/releases/" + namespace + "/" + chart;
$.ajax({ $.ajax({
url: url, url: url,
type: 'DELETE', type: 'DELETE',
@@ -277,9 +326,7 @@ $("#btnUninstall").click(function () {
const myModal = new bootstrap.Modal(document.getElementById('confirmModal')); const myModal = new bootstrap.Modal(document.getElementById('confirmModal'));
myModal.show() myModal.show()
let qstr = "name=" + chart + "&namespace=" + namespace + "&revision=" + revision let url = "/api/helm/releases/" + namespace + "/" + chart + "/resources"
let url = "/api/helm/charts/resources"
url += "?" + qstr
$.getJSON(url).fail(function (xhr) { $.getJSON(url).fail(function (xhr) {
reportError("Failed to get list of resources", xhr) reportError("Failed to get list of resources", xhr)
}).done(function (data) { }).done(function (data) {
@@ -301,10 +348,13 @@ $("#btnRollback").click(function () {
$("#confirmModalBody").empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') $("#confirmModalBody").empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
btnConfirm.prop("disabled", true).off('click').click(function () { btnConfirm.prop("disabled", true).off('click').click(function () {
btnConfirm.prop("disabled", true).append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') btnConfirm.prop("disabled", true).append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
const url = "/api/helm/charts/rollback?namespace=" + namespace + "&name=" + chart + "&revision=" + revisionNew; const url = "/api/helm/releases/" + namespace + "/" + chart + "/rollback";
$.ajax({ $.ajax({
url: url, url: url,
type: 'POST', type: 'POST',
data: {
revision: revisionNew
}
}).fail(function (xhr) { }).fail(function (xhr) {
reportError("Failed to rollback the chart", xhr) reportError("Failed to rollback the chart", xhr)
}).done(function () { }).done(function () {
@@ -315,8 +365,8 @@ $("#btnRollback").click(function () {
const myModal = new bootstrap.Modal(document.getElementById('confirmModal'), {}); const myModal = new bootstrap.Modal(document.getElementById('confirmModal'), {});
myModal.show() myModal.show()
let qstr = "name=" + chart + "&namespace=" + namespace + "&revision=" + revisionNew + "&revisionDiff=" + revisionCur let qstr = "revision=" + revisionNew + "&revisionDiff=" + revisionCur
let url = "/api/helm/charts/manifests" let url = "/api/helm/releases/" + namespace + "/" + chart + "/manifests"
url += "?" + qstr url += "?" + qstr
$.get(url).fail(function (xhr) { $.get(url).fail(function (xhr) {
reportError("Failed to get list of resources", xhr) reportError("Failed to get list of resources", xhr)
@@ -343,3 +393,25 @@ $("#btnAddRepository").click(function () {
setHashParam("section", "repository") setHashParam("section", "repository")
window.location.reload() window.location.reload()
}) })
$("#btnTest").click(function() {
const myModal = new bootstrap.Modal(document.getElementById('testModal'), {});
$("#testModal .test-result").empty().prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Waiting for completion...')
myModal.show()
$.ajax({
type: 'POST',
url: "/api/helm/releases/" + getHashParam("namespace") + "/" + getHashParam("chart") + "/test"
}).fail(function (xhr) {
reportError("Failed to execute test for chart", xhr)
myModal.hide()
}).done(function (data) {
var output;
if(data.length == 0 || data == null || data == "") {
output = "<div>Tests executed successfully<br><br><pre>Empty response from API<pre></div>"
} else {
output = data.replaceAll("\n", "<br>")
}
$("#testModal .test-result").empty().html(output)
myModal.show()
})
})

View File

@@ -0,0 +1,70 @@
<html lang="">
<head>
<link rel="icon" href="../static/logo.png"/>
<link rel="stylesheet" type="text/css"
href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui.css"/>
<title>
Helm Dashboard API
</title>
</head>
<body>
<div id="swagger-ui">
<div class="center_progress">
<div class="lds-dual-ring"></div>
</div>
</div>
</body>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui-bundle.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.15.5/swagger-ui-standalone-preset.js"></script>
<script>
let swaggerUrl = "openapi.json";
function reqOas() {
const request = new XMLHttpRequest();
request.open('GET', swaggerUrl, true);
request.setRequestHeader('Accept', 'application/json');
request.onload = function () {
if (request.status >= 200 && request.status < 400) {
// Success!
const data = JSON.parse(request.responseText);
display(data);
} else {
alert("Failed to get "+ swaggerUrl)
}
};
request.onerror = function () {
alert("Failed to get "+ swaggerUrl)
};
request.send();
}
function display(data) {
const parent = document.querySelectorAll('#swagger-ui')[0];
parent.innerHTML = '';
let el = document.createElement('div');
el.id = "swDocs";
parent.appendChild(el);
SwaggerUIBundle({
spec: data,
dom_id: '#' + el.id,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
]
});
}
$(function () {
reqOas();
});
</script>
</html>

View File

@@ -55,17 +55,17 @@ function loadContentWrapper() {
loadContent(getHashParam("tab"), getHashParam("namespace"), getHashParam("chart"), revision, revDiff, flag) loadContent(getHashParam("tab"), getHashParam("namespace"), getHashParam("chart"), revision, revDiff, flag)
} }
function loadContent(mode, namespace, name, revision, revDiff, flag) { function loadContent(mode, namespace, name, revision, revDiff, userDefined) {
let qstr = "name=" + name + "&namespace=" + namespace + "&revision=" + revision let qstr = "revision=" + revision
if (revDiff) { if (revDiff) {
qstr += "&revisionDiff=" + revDiff qstr += "&revisionDiff=" + revDiff
} }
if (flag) { if (userDefined) {
qstr += "&flag=" + flag qstr += "&userDefined=" + userDefined
} }
let url = "/api/helm/charts/" + mode let url = "/api/helm/releases/" + namespace + "/" + name + "/" + mode
url += "?" + qstr url += "?" + qstr
const diffDisplay = $("#manifestText"); const diffDisplay = $("#manifestText");
diffDisplay.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') diffDisplay.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
@@ -149,9 +149,7 @@ function showResources(namespace, chart, revision) {
const resBody = $("#nav-resources .body"); const resBody = $("#nav-resources .body");
const interestingResources = ["STATEFULSET", "DEAMONSET", "DEPLOYMENT"]; const interestingResources = ["STATEFULSET", "DEAMONSET", "DEPLOYMENT"];
resBody.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>'); resBody.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>');
let qstr = "name=" + chart + "&namespace=" + namespace + "&revision=" + revision let url = "/api/helm/releases/" + namespace + "/" + chart + "/resources"
let url = "/api/helm/charts/resources"
url += "?" + qstr
$.getJSON(url).fail(function (xhr) { $.getJSON(url).fail(function (xhr) {
reportError("Failed to get list of resources", xhr) reportError("Failed to get list of resources", xhr)
}).done(function (data) { }).done(function (data) {
@@ -182,7 +180,7 @@ function showResources(namespace, chart, revision) {
resBody.append(resBlock) resBody.append(resBlock)
let ns = res.metadata.namespace ? res.metadata.namespace : namespace let ns = res.metadata.namespace ? res.metadata.namespace : namespace
$.getJSON("/api/kube/resources/" + res.kind.toLowerCase() + "?name=" + res.metadata.name + "&namespace=" + ns).fail(function () { $.getJSON("/api/k8s/" + res.kind.toLowerCase() + "/get?name=" + res.metadata.name + "&namespace=" + ns).fail(function () {
//reportError("Failed to get list of resources") //reportError("Failed to get list of resources")
}).done(function (data) { }).done(function (data) {
const badge = $("<span class='badge me-2 fw-normal'></span>").text(data.status.phase); const badge = $("<span class='badge me-2 fw-normal'></span>").text(data.status.phase);
@@ -239,7 +237,7 @@ function showDescribe(ns, kind, name, badge) {
const myModal = new bootstrap.Offcanvas(document.getElementById('describeModal')); const myModal = new bootstrap.Offcanvas(document.getElementById('describeModal'));
myModal.show() myModal.show()
$.get("/api/kube/describe/" + kind.toLowerCase() + "?name=" + name + "&namespace=" + ns).fail(function (xhr) { $.get("/api/k8s/" + kind.toLowerCase() + "/describe?name=" + name + "&namespace=" + ns).fail(function (xhr) {
reportError("Failed to describe resource", xhr) reportError("Failed to describe resource", xhr)
}).done(function (data) { }).done(function (data) {
data = hljs.highlight(data, {language: 'yaml'}).value data = hljs.highlight(data, {language: 'yaml'}).value

View File

@@ -42,7 +42,7 @@
</a> </a>
<ul class="dropdown-menu fs-80"> <ul class="dropdown-menu fs-80">
<li><a class="dropdown-item" <li><a class="dropdown-item"
href="https://join.slack.com/t/komodorkommunity/shared_invite/zt-1dm3cnkue-ov1Yh~_95teA35QNx5yuMg" href="https://komodorkommunity.slack.com"
target="_blank"><i class="bi-slack"></i> Support Chat</a></li> target="_blank"><i class="bi-slack"></i> Support Chat</a></li>
<li><a class="dropdown-item" href="https://github.com/komodorio/helm-dashboard" target="_blank"><i <li><a class="dropdown-item" href="https://github.com/komodorio/helm-dashboard" target="_blank"><i
class="bi-github"></i> Project Page</a></li> class="bi-github"></i> Project Page</a></li>
@@ -54,7 +54,8 @@
<button class="dropdown-item" id="cacheClear"><i <button class="dropdown-item" id="cacheClear"><i
class="bi-arrow-repeat"></i> Reset Cache class="bi-arrow-repeat"></i> Reset Cache
</button> </button>
</li> <li><a class="dropdown-item" href="api-docs" target="_blank"><i
class="bi-braces"></i> REST API</a></li>
<li> <li>
<hr class="dropdown-divider"> <hr class="dropdown-divider">
</li> </li>
@@ -194,6 +195,10 @@
<button id="btnRollback" class="btn btn-sm btn-light bg-white border border-secondary me-2" <button id="btnRollback" class="btn btn-sm btn-light bg-white border border-secondary me-2"
title="Rollback to this revision"><i class="bi-arrow-repeat"></i> <span>Rollback</span> title="Rollback to this revision"><i class="bi-arrow-repeat"></i> <span>Rollback</span>
</button> </button>
<button id="btnTest"
class="btn btn-sm btn-light bg-white border border-secondary me-2 display-none"
title="Run tests for this chart"><i class="bi-check-circle"></i> <span>Run tests</span>
</button>
<button id="btnUninstall" class="btn btn-sm btn-light bg-white border border-secondary" <button id="btnUninstall" class="btn btn-sm btn-light bg-white border border-secondary"
title="Uninstall the chart"><i class="bi-trash3"></i> Uninstall title="Uninstall the chart"><i class="bi-trash3"></i> Uninstall
</button> </button>
@@ -364,14 +369,16 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form class="modal-body border-bottom fs-5" enctype="multipart/form-data"> <form class="modal-body border-bottom fs-5" enctype="multipart/form-data">
<input name="preview" type="hidden" class="preview-mode"/>
<input name="chart" type="hidden" class="chart-name"/>
<div class="input-group mb-3 text-muted"> <div class="input-group mb-3 text-muted">
<label class="form-label me-4 text-dark">Version to install: <select <label class="form-label me-4 text-dark">Version to install: <select
class='fw-bold text-success ver-new'></select></label> <span class="ver-old">(current version is <span class='fw-bold text-success ver-new' name="version"></select></label> <span class="ver-old">(current version is <span
class='text-success ms-1'>0.0.0</span>)</span> class='text-success ms-1'>0.0.0</span>)</span>
</div> </div>
<div class="input-group mb-3 text-muted"> <div class="input-group mb-3 text-muted">
<label class="form-label me-4 text-dark"> <label class="form-label me-4 text-dark">
Release Name: <input class="form-control rel-name"> Release Name: <input class="form-control rel-name" name="name">
</label> </label>
<label class="form-label me-4 text-dark"> <label class="form-label me-4 text-dark">
Namespace (optional): Namespace (optional):
@@ -417,6 +424,26 @@
</div> </div>
</div> </div>
<div class="modal" id="testModal" tabindex="-1">
<div class="modal-dialog modal-dialog modal-dialog-scrollable modal-xl">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="testModalLabel">
<span class="type">Test results</span>
</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body border-bottom fs-5">
<div class="row">
<div class="col">
<span class="test-result"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal --> <!-- Modal -->
<div class="modal fade" id="PowerOffModal" tabindex="-1" aria-labelledby="ModalLabel" aria-hidden="true"> <div class="modal fade" id="PowerOffModal" tabindex="-1" aria-labelledby="ModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
@@ -452,5 +479,47 @@
<script src="static/actions.js"></script> <script src="static/actions.js"></script>
<script src="static/scripts.js"></script> <script src="static/scripts.js"></script>
<!-- BANNER START -->
<a id="banner"
href="https://helm-dashboard-survey.komodor.com/"
class="display-none position-absolute top-0 start-50 translate-middle-x bg-primary text-light rounded px-2 mt-1 text-decoration-none py-1">Help
shaping the future by participating in user survey <b class="bi-x-lg"></b></a>
<script>
function setCookie(name, value, days) {
let expires = "";
if (days) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = "; expires=" + date.toUTCString();
}
document.cookie = name + "=" + (value || "") + expires + "; path=/";
}
function getCookie(name) {
const nameEQ = name + "=";
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
const c = ca[i].trim();
if (c.indexOf(nameEQ) === 0) {
return c.substring(nameEQ.length, c.length)
}
}
return null;
}
const cookie = getCookie("hideBanner");
if (cookie == null) {
console.log("show")
$("#banner").show()
}
$("#banner b").click(function (evt) {
evt.preventDefault()
setCookie("hideBanner", "1", 365);
$("#banner").hide()
})
</script>
<!-- /BANNER END -->
</body> </body>
</html> </html>

View File

@@ -3,7 +3,7 @@ function loadChartsList() {
$("#sectionList").show() $("#sectionList").show()
const chartsCards = $("#installedList .body") const chartsCards = $("#installedList .body")
chartsCards.empty().append("<div><span class=\"spinner-border spinner-border-sm\" role=\"status\" aria-hidden=\"true\"></span> Loading...</div>") chartsCards.empty().append("<div><span class=\"spinner-border spinner-border-sm\" role=\"status\" aria-hidden=\"true\"></span> Loading...</div>")
$.getJSON("/api/helm/charts").fail(function (xhr) { $.getJSON("/api/helm/releases").fail(function (xhr) {
sendStats('Get releases', {'status': 'failed'}); sendStats('Get releases', {'status': 'failed'});
reportError("Failed to get list of charts", xhr) reportError("Failed to get list of charts", xhr)
chartsCards.empty().append("<div class=\"row m-0 py-4 bg-white rounded-1 b-shadow border-4 border-start\"><div class=\"col\">Failed to get list of charts</div></div>") chartsCards.empty().append("<div class=\"row m-0 py-4 bg-white rounded-1 b-shadow border-4 border-start\"><div class=\"col\">Failed to get list of charts</div></div>")
@@ -44,37 +44,13 @@ function buildChartCard(elm) {
<div class="col-1 rel-date text-nowrap"><span>today</span><div>Updated</div></div> <div class="col-1 rel-date text-nowrap"><span>today</span><div>Updated</div></div>
</div>`) </div>`)
let chartName = elm.chart if (elm.icon) {
// semver2 regex , add optional v prefix card.find(".rel-name").attr("style", "background-image: url(" + elm.icon + ")")
const chartNameRegex = 'v?(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?'
const match = elm.chart.match(chartNameRegex);
if (match) {
chartName = elm.chart.substring(0, match.index - 1)
} else {
// fall back to simple substr
chartName = elm.chart.substring(0, elm.chart.lastIndexOf("-"))
}
$.getJSON("/api/helm/repo/search?name=" + chartName).fail(function (xhr) {
// we're ok if we can't show icon and description
console.log("Failed to get repo name for charts", xhr)
}).done(function (data) {
if (data.length > 0) {
$.getJSON("/api/helm/charts/show?name=" + data[0].name).fail(function (xhr) {
console.log("Failed to get chart", xhr)
}).done(function (data) {
if (data) {
const res = data[0];
if (res.icon) {
card.find(".rel-name").attr("style", "background-image: url(" + res.icon + ")")
}
if (res.description) {
card.find(".rel-name div").text(res.description)
}
} }
}) if (elm.description) {
card.find(".rel-name div").text(elm.description)
} }
})
card.find(".rel-name span").text(elm.name) card.find(".rel-name span").text(elm.name)
card.find(".rel-rev span").text("#" + elm.revision) card.find(".rel-rev span").text("#" + elm.revision)

View File

@@ -0,0 +1,749 @@
{
"openapi": "3.0.3",
"info": {
"title": "Helm Dashboard API",
"version": ""
},
"tags": [
{
"name": "Releases"
},
{
"name": "Repositories"
},
{
"name": "K8s"
},
{
"name": "Scanners"
},
{
"name": "Miscellaneous"
}
],
"paths": {
"/api/helm/releases": {
"get": {
"tags": [
"Releases"
],
"description": "Get list of installed releases",
"responses": {
"200": {
"description": "Returns list of installed releases"
}
}
}
},
"/api/helm/releases/{ns}": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace, use '[emtpy]' if you want to use k8s context default"
}
],
"post": {
"tags": [
"Releases"
],
"description": "Install new release",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"required": true
},
"chart": {
"type": "string",
"required": true
},
"version": {
"type": "string"
},
"values": {
"type": "string",
"description": "Text of values.yaml to use"
},
"preview": {
"type": "boolean"
}
}
}
}
}
},
"responses": {
"200": {
"description": "In case preview=true, the preview diff is generated",
"content": {
"text/plain": {}
}
},
"202": {
"description": "In case preview=false, the actial install is performed and resulting release object is returned",
"content": {
"application/json": {}
}
}
}
}
},
"/api/helm/releases/{ns}/{name}": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
}
],
"post": {
"tags": [
"Releases"
],
"description": "Upgrade/reconfigure existing release",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"required": true
},
"chart": {
"type": "string",
"required": true
},
"version": {
"type": "string"
},
"values": {
"type": "string",
"description": "Text of values.yaml to use"
},
"preview": {
"type": "boolean"
}
}
}
}
}
},
"responses": {
"200": {
"description": "In case preview=true, the preview diff is generated",
"content": {
"text/plain": {}
}
},
"202": {
"description": "In case preview=false, the actial install is performed and resulting release object is returned",
"content": {
"application/json": {}
}
}
}
}
},
"/api/helm/releases/{ns}/{name}/history": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
}
],
"get": {
"tags": [
"Releases"
],
"description": "Get revision history for release",
"responses": {
"200": {
"description": "List of release revisions"
}
}
}
},
"/api/helm/releases/{ns}/{name}/manifest": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
},
{
"name": "revision",
"in": "query",
"description": "Revision to get data from"
},
{
"name": "revisionDiff",
"in": "query",
"description": "Revision to diff against"
}
],
"get": {
"tags": [
"Releases"
],
"description": "Get manifest for release",
"responses": {
"200": {
"description": "Manifest text, or diff if revisionDiff is specified"
}
}
}
},
"/api/helm/releases/{ns}/{name}/values": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
},
{
"name": "revision",
"in": "query",
"description": "Revision to get data from"
},
{
"name": "revisionDiff",
"in": "query",
"description": "Revision to diff against"
},
{
"name": "userDefined",
"in": "query",
"description": "If set, only user-defined values will be listed"
}
],
"get": {
"tags": [
"Releases"
],
"description": "Get values for release",
"responses": {
"200": {
"description": "Values YAML text, or diff if revisionDiff is specified"
}
}
}
},
"/api/helm/releases/{ns}/{name}/notes": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
},
{
"name": "revision",
"in": "query",
"description": "Revision to get data from"
},
{
"name": "revisionDiff",
"in": "query",
"description": "Revision to diff against"
}
],
"get": {
"tags": [
"Releases"
],
"description": "Get textual notes for release",
"responses": {
"200": {
"description": "Notes text, or diff if revisionDiff is specified"
}
}
}
},
"/api/helm/releases/{ns}/{name}/resources": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
}
],
"get": {
"tags": [
"Releases"
],
"description": "List of installed k8s resources for this release",
"responses": {
"200": {
"description": "Structured list of resources",
"content": {
"application/json": {}
}
}
}
}
},
"/api/helm/releases/{ns}/{name}/rollback": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
}
],
"post": {
"tags": [
"Releases"
],
"description": "Rollback the release to a previous revision",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"properties": {
"revision": {
"type": "integer"
}
}
}
}
}
},
"responses": {
"202": {
"description": "Rolled back successfully"
}
}
}
},
"/api/helm/releases/{ns}/{name}/test": {
"parameters": [
{
"name": "ns",
"in": "path",
"description": "Name of kubernetes namespace"
},
{
"name": "name",
"in": "path",
"description": "Name of Helm release"
}
],
"post": {
"tags": [
"Releases"
],
"description": "Run the tests on a release",
"responses": {
"200": {
"description": "Logs of a test run"
}
}
}
},
"/api/helm/repositories": {
"get": {
"tags": [
"Repositories"
],
"description": "Get list of Helm repositories",
"responses": {
"200": {
"description": "Returns list of Helm repositories"
}
}
},
"post": {
"tags": [
"Repositories"
],
"description": "Adds new repository",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"name",
"url"
]
}
}
}
},
"responses": {
"204": {
"description": "Empty response in case repository were added"
}
}
}
},
"/api/helm/repositories/{repo}": {
"parameters": [
{
"name": "repo",
"in": "path",
"description": "Name of Helm repository"
}
],
"get": {
"tags": [
"Repositories"
],
"description": "Get list of charts in repository",
"responses": {
"200": {
"description": "Returns list of charts"
}
}
},
"post": {
"tags": [
"Repositories"
],
"description": "Update repository from remote",
"responses": {
"204": {
"description": "Empty response"
}
}
},
"delete": {
"tags": [
"Repositories"
],
"description": "Remove repository",
"responses": {
"204": {
"description": "Empty response"
}
}
}
},
"/api/helm/repositories/latestver": {
"parameters": [
{
"name": "name",
"in": "query",
"description": "Name of Helm chart to search for",
"required": true
}
],
"description": "Find the latest available version of specified chart through all the repositories",
"get": {
"tags": [
"Repositories"
],
"responses": {
"200": {
"description": "The object with latest available version is returned"
},
"204": {
"description": "In case no matching repository found, the response is empty with status 204"
}
}
}
},
"/api/helm/repositories/versions": {
"parameters": [
{
"name": "name",
"in": "query",
"description": "Name of Helm chart to search for",
"required": true
}
],
"get": {
"description": "Get the list of versions for specified chart across the repositories",
"tags": [
"Repositories"
],
"responses": {
"200": {
"description": "The list if chart versions is returned"
}
}
}
},
"/api/helm/repositories/values": {
"parameters": [
{
"name": "chart",
"in": "query",
"description": "Name of Helm chart to search for, in format of <repository>/<chart-name>",
"required": true
},
{
"name": "version",
"in": "query",
"description": "Version of Helm chart to get values from",
"required": true
}
],
"get": {
"description": "Get the original values.yaml file for the chart",
"tags": [
"Repositories"
],
"responses": {
"200": {
"description": "The content of values.yaml"
}
}
}
},
"/api/k8s/contexts": {
"get": {
"tags": [
"K8s"
],
"description": "Get list of kubectl contexts configured locally",
"responses": {
"200": {
"description": "Returns list of contexts"
}
}
}
},
"/api/k8s/{kind}/get": {
"parameters": [
{
"name": "kind",
"in": "path",
"description": "Kind of kubernetes resource"
},
{
"name": "name",
"in": "query",
"description": "Name of kubernetes resource",
"required": true
},
{
"name": "namespace",
"in": "query",
"description": "Namespace of kubernetes resource",
"required": true
}
],
"get": {
"tags": [
"K8s"
],
"responses": {
"200": {
"description": "Returns resources information"
}
}
}
},
"/api/k8s/{kind}/list": {
"parameters": [
{
"name": "kind",
"in": "path",
"description": "Kind of kubernetes resource",
"schema": {
"enum": [
"namespaces"
]
}
}
],
"get": {
"tags": [
"K8s"
],
"responses": {
"200": {
"description": "Returns list of resources"
}
}
}
},
"/api/k8s/{kind}/describe": {
"parameters": [
{
"name": "kind",
"in": "path",
"description": "Kind of kubernetes resource"
},
{
"name": "name",
"in": "query",
"description": "Name of kubernetes resource",
"required": true
},
{
"name": "namespace",
"in": "query",
"description": "Namespace of kubernetes resource",
"required": true
}
],
"get": {
"tags": [
"K8s"
],
"responses": {
"200": {
"content": {
"text/plain": {}
},
"description": "Returns describe text"
}
}
}
},
"/api/scanners": {
"get": {
"tags": [
"Scanners"
],
"description": "Get list of discovered scanners",
"responses": {
"200": {
"description": "List of scanners"
}
}
}
},
"/api/scanners/manifests": {
"post": {
"tags": [
"Scanners"
],
"description": "Scan manifests using all applicable scanners",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"manifest": {
"type": "string",
"description": "Text of manifest to scan"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Map of scan results per scanner type"
}
}
}
},
"/api/scanners/resource/{kind}": {
"parameters": [
{
"in": "path",
"name": "kind"
},
{
"in": "query",
"name": "namespace",
"required": true
},
{
"in": "query",
"name": "name",
"required": true
}
],
"get": {
"tags": [
"Scanners"
],
"description": "Scan specified k8s resource in cluster",
"responses": {
"200": {
"description": "Information with scan results per scanner type"
}
}
}
},
"/": {
"delete": {
"tags": [
"Miscellaneous"
],
"description": "Shuts down the Helm Dashboard application",
"responses": {
"202": {
"description": "Shutdown command has been accepted"
}
}
}
},
"/status": {
"get": {
"tags": [
"Miscellaneous"
],
"description": "Gets application status",
"responses": {
"200": {
"description": "Returns JSON with some options",
"headers": {
"X-Application-Name": {
"description": "A string to self-identify the application"
}
}
}
}
}
}
}
}

View File

@@ -2,7 +2,7 @@ function loadRepoView() {
$("#sectionRepo .repo-details").hide() $("#sectionRepo .repo-details").hide()
$("#sectionRepo").show() $("#sectionRepo").show()
$.getJSON("/api/helm/repo").fail(function (xhr) { $.getJSON("/api/helm/repositories").fail(function (xhr) {
reportError("Failed to get list of repositories", xhr) reportError("Failed to get list of repositories", xhr)
sendStats('Get repo', {'status': 'fail'}); sendStats('Get repo', {'status': 'fail'});
}).done(function (data) { }).done(function (data) {
@@ -31,7 +31,7 @@ function loadRepoView() {
$("#sectionRepo .repo-details .url").text(elm.url) $("#sectionRepo .repo-details .url").text(elm.url)
$("#sectionRepo .repo-details ul").html('<span class="spinner-border spinner-border-sm mx-1" role="status" aria-hidden="true"></span>') $("#sectionRepo .repo-details ul").html('<span class="spinner-border spinner-border-sm mx-1" role="status" aria-hidden="true"></span>')
$.getJSON("/api/helm/repo/charts?name=" + elm.name).fail(function (xhr) { $.getJSON("/api/helm/repositories/" + elm.name).fail(function (xhr) {
reportError("Failed to get list of charts in repo", xhr) reportError("Failed to get list of charts in repo", xhr)
}).done(function (data) { }).done(function (data) {
$("#sectionRepo .repo-details ul").empty() $("#sectionRepo .repo-details ul").empty()
@@ -42,6 +42,11 @@ function loadRepoView() {
<div class="col-1 py-2">` + elm.version + `</div> <div class="col-1 py-2">` + elm.version + `</div>
<div class="col-1 action text-nowrap"><button class="btn btn-sm border-secondary bg-white">Install</button></div> <div class="col-1 action text-nowrap"><button class="btn btn-sm border-secondary bg-white">Install</button></div>
</li>`) </li>`)
if (elm.icon) {
li.find("h6").prepend('<img src="' + elm.icon + '" class="me-1" style="height: 1rem"/>')
}
li.data("item", elm) li.data("item", elm)
if (elm.installed_namespace) { if (elm.installed_namespace) {
@@ -86,7 +91,7 @@ $("#repoAddModal .btn-confirm").click(function () {
$("#repoAddModal .btn-confirm").prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm mx-1" role="status" aria-hidden="true"></span>') $("#repoAddModal .btn-confirm").prop("disabled", true).prepend('<span class="spinner-border spinner-border-sm mx-1" role="status" aria-hidden="true"></span>')
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: "/api/helm/repo", url: "/api/helm/repositories",
data: $("#repoAddModal form").serialize(), data: $("#repoAddModal form").serialize(),
}).fail(function (xhr) { }).fail(function (xhr) {
reportError("Failed to add repo", xhr) reportError("Failed to add repo", xhr)
@@ -100,7 +105,7 @@ $("#sectionRepo .btn-remove").click(function () {
if (confirm("Confirm removing repository?")) { if (confirm("Confirm removing repository?")) {
$.ajax({ $.ajax({
type: 'DELETE', type: 'DELETE',
url: "/api/helm/repo?name=" + $("#sectionRepo .repo-details h2").text(), url: "/api/helm/repositories/" + $("#sectionRepo .repo-details h2").text(),
}).fail(function (xhr) { }).fail(function (xhr) {
reportError("Failed to add repo", xhr) reportError("Failed to add repo", xhr)
}).done(function () { }).done(function () {
@@ -114,7 +119,7 @@ $("#sectionRepo .btn-update").click(function () {
$("#sectionRepo .btn-update i").removeClass("bi-arrow-repeat").append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') $("#sectionRepo .btn-update i").removeClass("bi-arrow-repeat").append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: "/api/helm/repo/update?name=" + $("#sectionRepo .repo-details h2").text(), url: "/api/helm/repositories/" + $("#sectionRepo .repo-details h2").text(),
}).fail(function (xhr) { }).fail(function (xhr) {
reportError("Failed to add repo", xhr) reportError("Failed to add repo", xhr)
}).done(function () { }).done(function () {
@@ -132,8 +137,11 @@ function repoChartClicked() {
window.location.reload() window.location.reload()
} else { } else {
const contexts = $("body").data("contexts") const contexts = $("body").data("contexts")
const ctxFiltered = contexts.filter(obj => {return obj.Name === getHashParam("context")}); const ctxFiltered = contexts.filter(obj => {
return obj.Name === getHashParam("context")
});
const contextNamespace = ctxFiltered.length ? ctxFiltered[0].Namespace : "" const contextNamespace = ctxFiltered.length ? ctxFiltered[0].Namespace : ""
elm.repository = $("#sectionRepo .repo-details h2").text()
popUpUpgrade(elm, contextNamespace) popUpUpgrade(elm, contextNamespace)
} }
} }

View File

@@ -5,12 +5,14 @@ function loadChartHistory(namespace, name) {
$("#sectionDetails").show() $("#sectionDetails").show()
$("#sectionDetails .name").text(name) $("#sectionDetails .name").text(name)
revRow.empty().append("<li><span class=\"spinner-border spinner-border-sm\" role=\"status\" aria-hidden=\"true\"></span></li>") revRow.empty().append("<li><span class=\"spinner-border spinner-border-sm\" role=\"status\" aria-hidden=\"true\"></span></li>")
$.getJSON("/api/helm/charts/history?name=" + name + "&namespace=" + namespace).fail(function (xhr) { $.getJSON("/api/helm/releases/" + namespace + "/" + name + "/history").fail(function (xhr) {
reportError("Failed to get chart details", xhr) reportError("Failed to get chart details", xhr)
}).done(function (data) { }).done(function (data) {
fillChartHistory(data, namespace, name); fillChartHistory(data, namespace, name);
checkUpgradeable(data[data.length - 1].chart_name) checkUpgradeable(data[0].chart_name)
$("#btnTest").toggle(data[0].has_tests)
const rev = getHashParam("revision") const rev = getHashParam("revision")
if (rev) { if (rev) {

View File

@@ -45,7 +45,7 @@ function fillClusters(limNS) {
filterInstalledList($("#installedList .body .row")) filterInstalledList($("#installedList .body .row"))
}) })
$.getJSON("/api/kube/contexts").fail(function (xhr) { $.getJSON("/api/k8s/contexts").fail(function (xhr) {
sendStats('contexts', {'status': 'fail'}); sendStats('contexts', {'status': 'fail'});
reportError("Failed to get list of clusters", xhr) reportError("Failed to get list of clusters", xhr)
}).done(function (data) { }).done(function (data) {
@@ -54,7 +54,7 @@ function fillClusters(limNS) {
data.sort((a, b) => (getCleanClusterName(a.Name) > getCleanClusterName(b.Name)) - (getCleanClusterName(a.Name) < getCleanClusterName(b.Name))) data.sort((a, b) => (getCleanClusterName(a.Name) > getCleanClusterName(b.Name)) - (getCleanClusterName(a.Name) < getCleanClusterName(b.Name)))
fillClusterList(data, context); fillClusterList(data, context);
sendStats('contexts', {'status': 'success', length: data.length}); sendStats('contexts', {'status': 'success', length: data.length});
$.getJSON("/api/kube/namespaces").fail(function (xhr) { $.getJSON("/api/k8s/namespaces/list").fail(function (xhr) {
reportError("Failed to get namespaces", xhr) reportError("Failed to get namespaces", xhr)
}).done(function (res) { }).done(function (res) {
const ns = res.items.map(i => i.metadata.name) const ns = res.items.map(i => i.metadata.name)

View File

@@ -89,10 +89,11 @@ body > .container-fluid {
#filters { #filters {
overflow: hidden; overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
font-size: 0.8rem; font-size: 0.8rem;
line-height: 175%; line-height: 175%;
inline-size: auto;
overflow-wrap: break-word;
} }
#cluster input, #cluster span { #cluster input, #cluster span {
@@ -296,3 +297,7 @@ nav .nav-tabs .nav-link.active {
#sectionRepo .repo-details ul .row:hover .btn { #sectionRepo .repo-details ul .row:hover .btn {
visibility: visible; visibility: visible;
} }
.test-result {
font-size: 1rem;
}

View File

@@ -1,307 +0,0 @@
package subproc
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/release"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
)
type DataLayer struct {
KubeContext string
Helm string
Kubectl string
Scanners []Scanner
StatusInfo *StatusInfo
Namespace string
Cache *Cache
}
type StatusInfo struct {
CurVer string
LatestVer string
Analytics bool
LimitedToNamespace string
CacheHitRatio float64
ClusterMode bool
}
func (d *DataLayer) runCommand(cmd ...string) (string, error) {
for i, c := range cmd {
// TODO: remove namespace parameter if it's empty
if c == "--namespace" && i < len(cmd) { // TODO: in case it's not found - add it?
d.forceNamespace(&cmd[i+1])
}
}
return utils.RunCommand(cmd, map[string]string{"HELM_KUBECONTEXT": d.KubeContext})
}
func (d *DataLayer) runCommandHelm(cmd ...string) (string, error) {
if d.Helm == "" {
d.Helm = "helm"
}
cmd = append([]string{d.Helm}, cmd...)
if d.KubeContext != "" {
cmd = append(cmd, "--kube-context", d.KubeContext)
}
return d.runCommand(cmd...)
}
func (d *DataLayer) forceNamespace(s *string) {
if d.Namespace != "" {
*s = d.Namespace
}
}
func (d *DataLayer) CheckConnectivity() error {
contexts, err := d.ListContexts()
if err != nil {
return err
}
if len(contexts) < 1 {
log.Debugf("Did not find any contexts, will try checking k8s")
_, err := d.runCommandKubectl("get", "pods")
if err != nil {
log.Debugf("The error were: %s", err)
return errors.New("did not find any kubectl contexts configured")
}
log.Infof("Assuming k8s environment")
d.StatusInfo.ClusterMode = true
}
_, err = d.runCommandHelm("--help")
if err != nil {
return err
}
return nil
}
func (d *DataLayer) GetStatus() *StatusInfo {
sum := float64(d.Cache.HitCount + d.Cache.MissCount)
if sum > 0 {
d.StatusInfo.CacheHitRatio = float64(d.Cache.HitCount) / sum
} else {
d.StatusInfo.CacheHitRatio = 0
}
return d.StatusInfo
}
func (d *DataLayer) ListInstalled() (res []ReleaseElement, err error) {
cmd := []string{"ls", "--all", "--output", "json", "--time-format", time.RFC3339}
// TODO: filter by namespace
if d.Namespace == "" {
cmd = append(cmd, "--all-namespaces")
} else {
cmd = append(cmd, "--namespace", d.Namespace)
}
out, err := d.Cache.String(CacheKeyRelList, nil, func() (string, error) {
return d.runCommandHelm(cmd...)
})
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return nil, err
}
return res, nil
}
func (d *DataLayer) ReleaseHistory(namespace string, releaseName string) (res []*HistoryElement, err error) {
// TODO: there is `max` but there is no `offset`
ct := cacheTagRelease(namespace, releaseName)
out, err := d.Cache.String(CacheKeyRelHistory+ct, []string{ct}, func() (string, error) {
return d.runCommandHelm("history", releaseName, "--namespace", namespace, "--output", "json")
})
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return nil, err
}
for _, elm := range res {
chartRepoName, curVer, err := utils.ChartAndVersion(elm.Chart)
if err != nil {
return nil, err
}
elm.ChartName = chartRepoName // TODO: move it to frontend?
elm.ChartVer = curVer
elm.Updated.Time = elm.Updated.Time.Round(time.Second)
}
return res, nil
}
type SectionFn = func(string, string, int, bool) (string, error) // TODO: rework it into struct-based argument?
func (d *DataLayer) RevisionManifests(namespace string, chartName string, revision int, _ bool) (res string, err error) {
cmd := []string{"get", "manifest", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision)}
key := CacheKeyRevManifests + "\v" + namespace + "\v" + chartName + "\v" + strconv.Itoa(revision)
return d.Cache.String(key, nil, func() (string, error) {
return d.runCommandHelm(cmd...)
})
}
func (d *DataLayer) RevisionManifestsParsed(namespace string, chartName string, revision int) ([]*v1.Carp, error) {
out, err := d.RevisionManifests(namespace, chartName, revision, false)
if err != nil {
return nil, err
}
dec := yaml.NewDecoder(bytes.NewReader([]byte(out)))
res := make([]*v1.Carp, 0)
var tmp interface{}
for dec.Decode(&tmp) == nil {
// k8s libs uses only JSON tags defined, say hello to https://github.com/go-yaml/yaml/issues/424
// we can juggle it
jsoned, err := json.Marshal(tmp)
if err != nil {
return nil, err
}
var doc v1.Carp
err = json.Unmarshal(jsoned, &doc)
if err != nil {
return nil, err
}
if doc.Kind == "" {
log.Warnf("Manifest piece is not k8s resource: %s", jsoned)
continue
}
res = append(res, &doc)
}
return res, nil
}
func (d *DataLayer) RevisionNotes(namespace string, chartName string, revision int, _ bool) (res string, err error) {
cmd := []string{"get", "notes", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision)}
key := CacheKeyRevNotes + "\v" + namespace + "\v" + chartName + "\v" + strconv.Itoa(revision)
return d.Cache.String(key, nil, func() (string, error) {
return d.runCommandHelm(cmd...)
})
}
func (d *DataLayer) RevisionValues(namespace string, chartName string, revision int, onlyUserDefined bool) (res string, err error) {
cmd := []string{"get", "values", chartName, "--namespace", namespace, "--output", "yaml", "--revision", strconv.Itoa(revision)}
if !onlyUserDefined {
cmd = append(cmd, "--all")
}
key := CacheKeyRevValues + "\v" + namespace + "\v" + chartName + "\v" + strconv.Itoa(revision) + "\v" + fmt.Sprintf("%v", onlyUserDefined)
return d.Cache.String(key, nil, func() (string, error) {
return d.runCommandHelm(cmd...)
})
}
func (d *DataLayer) ReleaseUninstall(namespace string, name string) error {
d.Cache.Invalidate(CacheKeyRelList, cacheTagRelease(namespace, name))
_, err := d.runCommandHelm("uninstall", name, "--namespace", namespace)
return err
}
func (d *DataLayer) Rollback(namespace string, name string, rev int) error {
d.Cache.Invalidate(CacheKeyRelList, cacheTagRelease(namespace, name))
_, err := d.runCommandHelm("rollback", name, strconv.Itoa(rev), "--namespace", namespace)
return err
}
func (d *DataLayer) ChartInstall(namespace string, name string, repoChart string, version string, justTemplate bool, values string, reuseVals bool) (string, error) {
if values == "" && reuseVals {
oldVals, err := d.RevisionValues(namespace, name, 0, true)
if err != nil {
return "", err
}
values = oldVals
}
valsFile, close1, err := utils.TempFile(values)
defer close1()
if err != nil {
return "", err
}
cmd := []string{"upgrade", "--install", "--create-namespace", name, repoChart, "--version", version, "--namespace", namespace, "--values", valsFile, "--output", "json"}
if justTemplate {
cmd = append(cmd, "--dry-run")
}
out, err := d.runCommandHelm(cmd...)
if err != nil {
return "", err
}
if !justTemplate {
d.Cache.Invalidate(CacheKeyRelList, cacheTagRelease(namespace, name))
}
res := release.Release{}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return "", err
}
if justTemplate {
out = strings.TrimSpace(res.Manifest)
}
return out, nil
}
func RevisionDiff(functor SectionFn, ext string, namespace string, name string, revision1 int, revision2 int, flag bool) (string, error) {
if revision1 == 0 || revision2 == 0 {
log.Debugf("One of revisions is zero: %d %d", revision1, revision2)
return "", nil
}
manifest1, err := functor(namespace, name, revision1, flag)
if err != nil {
return "", err
}
manifest2, err := functor(namespace, name, revision2, flag)
if err != nil {
return "", err
}
diff := GetDiff(manifest1, manifest2, strconv.Itoa(revision1)+ext, strconv.Itoa(revision2)+ext)
return diff, nil
}
func GetDiff(text1 string, text2 string, name1 string, name2 string) string {
edits := myers.ComputeEdits(span.URIFromPath(""), text1, text2)
unified := gotextdiff.ToUnified(name1, name2, text1, edits)
diff := fmt.Sprint(unified)
log.Debugf("The diff is: %s", diff)
return diff
}

View File

@@ -1,90 +0,0 @@
package subproc
import (
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus"
"helm.sh/helm/v3/pkg/release"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"sync"
"testing"
)
func TestFlow(t *testing.T) {
log.SetLevel(log.DebugLevel)
var _ release.Status
data := DataLayer{
Cache: NewCache(),
}
err := data.CheckConnectivity()
if err != nil {
if err.Error() == "did not find any kubectl contexts configured" {
t.Skip()
} else {
t.Fatal(err)
}
}
ctxses, err := data.ListContexts()
if err != nil {
t.Fatal(err)
}
for _, ctx := range ctxses {
if ctx.IsCurrent {
data.KubeContext = ctx.Name
}
}
installed, err := data.ListInstalled()
if err != nil {
t.Fatal(err)
}
chart := installed[1]
history, err := data.ReleaseHistory(chart.Namespace, chart.Name)
if err != nil {
t.Fatal(err)
}
_ = history
chartRepoName, curVer, err := utils.ChartAndVersion(chart.Chart)
if err != nil {
t.Fatal(err)
}
_ = curVer
upgrade, err := data.ChartRepoVersions(chartRepoName)
if err != nil {
t.Fatal(err)
}
_ = upgrade
manifests, err := data.RevisionManifestsParsed(chart.Namespace, chart.Name, history[len(history)-1].Revision)
if err != nil {
t.Fatal(err)
}
_ = manifests
var wg sync.WaitGroup
res := make([]*v1.Carp, 0)
for _, m := range manifests {
wg.Add(1)
mc := m // fix the clojure
func() {
defer wg.Done()
lst, err := data.GetResource(chart.Namespace, mc)
if err != nil {
t.Fatal(err)
}
res = append(res, lst)
}()
}
wg.Wait()
diff, err := RevisionDiff(data.RevisionManifests, ".yaml", chart.Namespace, chart.Name, history[len(history)-1].Revision, history[len(history)-2].Revision, true)
if err != nil {
t.Fatal(err)
}
_ = diff
}

View File

@@ -1,45 +0,0 @@
package subproc
import (
"helm.sh/helm/v3/pkg/release"
helmtime "helm.sh/helm/v3/pkg/time"
)
// unpleasant copy from Helm sources, where they have it non-public
type ReleaseElement struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Revision string `json:"revision"`
Updated helmtime.Time `json:"updated"`
Status release.Status `json:"status"`
Chart string `json:"chart"`
AppVersion string `json:"app_version"`
}
type HistoryElement struct {
Revision int `json:"revision"`
Updated helmtime.Time `json:"updated"`
Status release.Status `json:"status"`
Chart string `json:"chart"`
AppVersion string `json:"app_version"`
Description string `json:"description"`
ChartName string `json:"chart_name"` // custom addition on top of Helm
ChartVer string `json:"chart_ver"` // custom addition on top of Helm
}
type RepoChartElement struct {
Name string `json:"name"`
Version string `json:"version"`
AppVersion string `json:"app_version"`
Description string `json:"description"`
InstalledNamespace string `json:"installed_namespace"` // custom addition on top of Helm
InstalledName string `json:"installed_name"` // custom addition on top of Helm
}
type RepositoryElement struct {
Name string `json:"name"`
URL string `json:"url"`
}

View File

@@ -1,151 +0,0 @@
package subproc
import (
"encoding/json"
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
"os"
"regexp"
"sort"
"strings"
)
func (d *DataLayer) runCommandKubectl(cmd ...string) (string, error) {
if d.Kubectl == "" {
d.Kubectl = "kubectl"
}
cmd = append([]string{d.Kubectl}, cmd...)
if d.KubeContext != "" {
cmd = append(cmd, "--context", d.KubeContext)
}
return d.runCommand(cmd...)
}
type KubeContext struct {
IsCurrent bool
Name string
Cluster string
AuthInfo string
Namespace string
}
func (d *DataLayer) ListContexts() (res []KubeContext, err error) {
res = []KubeContext{}
if os.Getenv("HD_CLUSTER_MODE") != "" {
return res, nil
}
out, err := d.runCommandKubectl("config", "get-contexts")
if err != nil {
return nil, err
}
// kubectl has no JSON output for it, we'll have to do custom text parsing
lines := strings.Split(out, "\n")
// find field positions
fields := regexp.MustCompile(`(\w+\s+)`).FindAllString(lines[0], -1)
cur := len(fields[0])
name := cur + len(fields[1])
cluster := name + len(fields[2])
auth := cluster + len(fields[3])
// read items
for _, line := range lines[1:] {
if strings.TrimSpace(line) == "" {
continue
}
res = append(res, KubeContext{
IsCurrent: strings.TrimSpace(line[0:cur]) == "*",
Name: strings.TrimSpace(line[cur:name]),
Cluster: strings.TrimSpace(line[name:cluster]),
AuthInfo: strings.TrimSpace(line[cluster:auth]),
Namespace: strings.TrimSpace(line[auth:]),
})
}
return res, nil
}
type NamespaceElement struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
} `json:"items"`
}
func (d *DataLayer) GetNameSpaces() (res *NamespaceElement, err error) {
out, err := d.runCommandKubectl("get", "namespaces", "-o", "json")
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return nil, err
}
return res, nil
}
func (d *DataLayer) GetResource(namespace string, def *v1.Carp) (*v1.Carp, error) {
out, err := d.runCommandKubectl("get", strings.ToLower(def.Kind), def.Name, "--namespace", namespace, "--output", "json")
if err != nil {
if strings.HasSuffix(strings.TrimSpace(err.Error()), " not found") {
return &v1.Carp{
Status: v1.CarpStatus{
Phase: "NotFound",
Message: err.Error(),
Reason: "not found",
},
}, nil
} else {
return nil, err
}
}
var res v1.Carp
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return nil, err
}
sort.Slice(res.Status.Conditions, func(i, j int) bool {
// some condition types always bubble up
if res.Status.Conditions[i].Type == "Available" {
return false
}
if res.Status.Conditions[j].Type == "Available" {
return true
}
t1 := res.Status.Conditions[i].LastTransitionTime
t2 := res.Status.Conditions[j].LastTransitionTime
return t1.Time.Before(t2.Time)
})
return &res, nil
}
func (d *DataLayer) GetResourceYAML(namespace string, def *v1.Carp) (string, error) {
out, err := d.runCommandKubectl("get", strings.ToLower(def.Kind), def.Name, "--namespace", namespace, "--output", "yaml")
if err != nil {
return "", err
}
return out, nil
}
func (d *DataLayer) DescribeResource(namespace string, kind string, name string) (string, error) {
out, err := d.runCommandKubectl("describe", strings.ToLower(kind), name, "--namespace", namespace)
if err != nil {
return "", err
}
return out, nil
}

View File

@@ -1,164 +0,0 @@
package subproc
import (
"bytes"
"encoding/json"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"helm.sh/helm/v3/pkg/chart"
"strings"
)
func (d *DataLayer) ChartRepoList() (res []RepositoryElement, err error) {
out, err := d.Cache.String(CacheKeyAllRepos, nil, func() (string, error) {
// TODO: do a bg check, if the state is changed - do reset some caches
return d.runCommandHelm("repo", "list", "--output", "json")
})
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return nil, err
}
return res, nil
}
func (d *DataLayer) ChartRepoAdd(name string, url string) (string, error) {
d.Cache.Invalidate(CacheKeyAllRepos)
out, err := d.runCommandHelm("repo", "add", "--force-update", name, url)
if err != nil {
return "", err
}
return out, nil
}
func (d *DataLayer) ChartRepoDelete(name string) (string, error) {
d.Cache.Invalidate(CacheKeyAllRepos)
out, err := d.runCommandHelm("repo", "remove", name)
if err != nil {
return "", err
}
return out, nil
}
func (d *DataLayer) ChartRepoUpdate(name string) error {
d.Cache.Invalidate(cacheTagRepoName(name), CacheKeyAllRepos)
cmd := []string{"repo", "update"}
if name != "" {
cmd = append(cmd, name)
}
_, err := d.runCommandHelm(cmd...)
return err
}
func (d *DataLayer) ChartRepoVersions(chartName string) (res []*RepoChartElement, err error) {
search := "/" + chartName + "\v"
if strings.Contains(chartName, "/") {
search = "\v" + chartName + "\v"
}
cmd := []string{"search", "repo", "--regexp", search, "--versions", "--output", "json"}
out, err := d.Cache.String(cacheTagRepoVers(chartName), []string{CacheKeyAllRepos}, func() (string, error) {
return d.runCommandHelm(cmd...)
})
if err != nil {
if strings.Contains(err.Error(), "no repositories configured") {
out = "[]"
} else {
return nil, err
}
}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return nil, err
}
return res, nil
}
func (d *DataLayer) ChartRepoCharts(repoName string) (res []*RepoChartElement, err error) {
cmd := []string{"search", "repo", "--regexp", "\v" + repoName + "/", "--output", "json"}
out, err := d.Cache.String(cacheTagRepoCharts(repoName), []string{CacheKeyAllRepos}, func() (string, error) {
return d.runCommandHelm(cmd...)
})
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(out), &res)
if err != nil {
return nil, err
}
ins, err := d.ListInstalled()
if err != nil {
return nil, err
}
enrichRepoChartsWithInstalled(res, ins)
return res, nil
}
func enrichRepoChartsWithInstalled(charts []*RepoChartElement, installed []ReleaseElement) {
for _, rchart := range charts {
for _, rel := range installed {
c, _, err := utils.ChartAndVersion(rel.Chart)
if err != nil {
log.Warnf("Failed to parse chart: %s", err)
continue
}
pieces := strings.Split(rchart.Name, "/")
if pieces[1] == c {
// TODO: there can be more than one
rchart.InstalledNamespace = rel.Namespace
rchart.InstalledName = rel.Name
}
}
}
}
// ShowValues get values from repo chart, not from installed release
func (d *DataLayer) ShowValues(chart string, ver string) (string, error) {
return d.Cache.String(CacheKeyRepoChartValues+"\v"+chart+"\v"+ver, nil, func() (string, error) {
return d.runCommandHelm("show", "values", chart, "--version", ver)
})
}
func (d *DataLayer) ShowChart(chartName string) ([]*chart.Metadata, error) { // TODO: add version parameter to method
out, err := d.Cache.String(CacheKeyShowChart+"\v"+chartName, []string{"chart\v" + chartName}, func() (string, error) {
return d.runCommandHelm("show", "chart", chartName)
})
if err != nil {
return nil, err
}
deccoder := yaml.NewDecoder(bytes.NewReader([]byte(out)))
res := make([]*chart.Metadata, 0)
var tmp interface{}
for deccoder.Decode(&tmp) == nil {
jsoned, err := json.Marshal(tmp)
if err != nil {
return nil, err
}
var resjson chart.Metadata
err = json.Unmarshal(jsoned, &resjson)
if err != nil {
return nil, err
}
res = append(res, &resjson)
}
return res, nil
}

View File

@@ -7,7 +7,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"regexp" "regexp"
"strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -105,10 +104,9 @@ func RunCommand(cmd []string, env map[string]string) (string, error) {
type QueryProps struct { type QueryProps struct {
Namespace string Namespace string
Name string Name string
Revision int
} }
func GetQueryProps(c *gin.Context, revRequired bool) (*QueryProps, error) { func GetQueryProps(c *gin.Context) (*QueryProps, error) {
qp := QueryProps{} qp := QueryProps{}
qp.Namespace = c.Query("namespace") qp.Namespace = c.Query("namespace")
@@ -117,11 +115,5 @@ func GetQueryProps(c *gin.Context, revRequired bool) (*QueryProps, error) {
return nil, errors.New("missing required query string parameter: name") return nil, errors.New("missing required query string parameter: name")
} }
cRev, err := strconv.Atoi(c.Query("revision"))
if err != nil && revRequired {
return nil, err
}
qp.Revision = cRev
return &qp, nil return &qp, nil
} }

View File

@@ -12,31 +12,21 @@ func TestGetQueryProps(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
endpoint string endpoint string
revRequired bool
wantErr bool wantErr bool
}{ }{
{ {
name: "Get query props - all set with revRequired true", name: "Get query props - all set with revRequired true",
wantErr: false, wantErr: false,
revRequired: true,
endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&namespace=testing&revision=1", endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&namespace=testing&revision=1",
}, },
{
name: "Get query props - no revision with revRequired true",
wantErr: true,
revRequired: true,
endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&namespace=testing",
},
{ {
name: "Get query props - no namespace with revRequired true", name: "Get query props - no namespace with revRequired true",
wantErr: false, wantErr: false,
revRequired: true,
endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&revision=1", endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&revision=1",
}, },
{ {
name: "Get query props - no name with revRequired true", name: "Get query props - no name with revRequired true",
wantErr: true, wantErr: true,
revRequired: true,
endpoint: "/api/v1/namespaces/komodorio/charts?namespace=testing&revision=1", endpoint: "/api/v1/namespaces/komodorio/charts?namespace=testing&revision=1",
}, },
} }
@@ -46,7 +36,7 @@ func TestGetQueryProps(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", tt.endpoint, nil) c.Request = httptest.NewRequest("GET", tt.endpoint, nil)
_, err := GetQueryProps(c, tt.revRequired) _, err := GetQueryProps(c)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("GetQueryProps() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("GetQueryProps() error = %v, wantErr %v", err, tt.wantErr)
return return

View File

@@ -1,5 +1,5 @@
name: "dashboard" name: "dashboard"
version: "0.3.0" version: "0.3.1"
usage: "A simplified way of working with Helm" usage: "A simplified way of working with Helm"
description: "View HELM situation in nice web UI" description: "View HELM situation in nice web UI"
command: "$HELM_PLUGIN_DIR/bin/helm-dashboard" command: "$HELM_PLUGIN_DIR/bin/helm-dashboard"

BIN
screenshot_run_test.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
screenshot_run_test_result.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -2,16 +2,23 @@
# Copied w/ love from the chartmuseum/helm-push :) # Copied w/ love from the chartmuseum/helm-push :)
[ -z "$HELM_DEBUG" ] || set -x
name="helm-dashboard" name="helm-dashboard"
repo="https://github.com/komodorio/${name}" repo="https://github.com/komodorio/${name}"
api_repo="https://api.github.com/repos/komodorio/${name}/releases/latest"
if [ -n "${HELM_PUSH_PLUGIN_NO_INSTALL_HOOK}" ]; then if [ -n "${HELM_PUSH_PLUGIN_NO_INSTALL_HOOK}" ]; then
echo "Development mode: not downloading versioned release." echo "Development mode: not downloading versioned release."
exit 0 exit 0
fi fi
version="$(curl -s ${api_repo} | grep '\"name\": "v.*\"' | cut -d 'v' -f 2 | cut -d '"' -f 1)"
echo Tried to autodetect latest version: $version
[ -z "$version" ] && {
version="$(cat plugin.yaml | grep "version" | cut -d '"' -f 2)" version="$(cat plugin.yaml | grep "version" | cut -d '"' -f 2)"
# TODO: if no version provided, get it from https://api.github.com/repos/komodorio/helm-dashboard/releases/latest echo Defaulted to version: $version
}
echo "Downloading and installing ${name} v${version} ..." echo "Downloading and installing ${name} v${version} ..."
url="" url=""