[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>
This commit is contained in:
Andrey Pokhilko
2023-02-01 13:24:34 +00:00
committed by GitHub
parent 6ffcdf2b8e
commit e13aa2fde6
44 changed files with 4338 additions and 1278 deletions

6
.github/labeler.yml vendored
View File

@@ -60,5 +60,9 @@ release:
scanners: scanners:
- pkg/dashboard/scanners/* - pkg/dashboard/scanners/*
tests:
- pkg/dashboard/**/*_test.go
- pkg/dashboard/objects/testdata/*
frontend: frontend:
- pkg/dashboard/static/* - pkg/dashboard/static/*

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

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

@@ -31,9 +31,15 @@ Some of the key capabilities of the tool:
## 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
@@ -72,7 +78,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.
@@ -84,9 +90,6 @@ 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 ## Execute Helm tests

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+

131
go.mod
View File

@@ -3,68 +3,169 @@ 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
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/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/stretchr/testify v1.8.1 // indirect
)
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.6 // 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/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/common v0.33.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/procfs v0.7.3 // 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 // indirect
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
) )

547
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) {
c.Header("Cache-Control", "no-cache") if c.GetHeader("Cache-Control") == "" { // default policy is not to 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 { return
_ = c.AbortWithError(http.StatusBadRequest, err)
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,40 +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{
Data: data,
},
}
api.GET("/charts", h.GetCharts) rels := api.Group("/releases")
api.DELETE("/charts", h.Uninstall) 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)
api.GET("/charts/history", h.History) repos := api.Group("/repositories")
api.GET("/charts/resources", h.Resources) repos.GET("", h.RepoList)
api.GET("/charts/:section", h.GetInfoSection) repos.POST("", h.RepoAdd)
api.GET("/charts/show", h.Show) repos.GET("/:name", h.RepoCharts)
api.POST("/charts/install", h.Install) repos.POST("/:name", h.RepoUpdate)
api.POST("/charts/tests", h.Tests) repos.DELETE("/:name", h.RepoDelete)
api.POST("/charts/rollback", h.Rollback) repos.GET("/latestver", h.RepoLatestVer) // TODO: use /versions in client insted and remove this?
repos.GET("/versions", h.RepoVersions)
api.GET("/repo", h.RepoList) repos.GET("/values", h.RepoValues)
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) { func configureKubectls(api *gin.RouterGroup, data *objects.DataLayer) {
h := handlers.KubeHandler{Data: data} 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) {
@@ -162,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)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err) rel := h.getRelease(c)
return if rel == nil {
return // error state is set inside
} }
res, err := h.Data.RevisionManifestsParsed(qp.Namespace, qp.Name, qp.Revision) res, err := objects.ParseManifests(rel.Orig.Manifest)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
} }
c.IndentedJSON(http.StatusOK, res) c.IndentedJSON(http.StatusOK, res)
} }
func (h *HelmHandler) RepoSearch(c *gin.Context) { func (h *HelmHandler) RepoVersions(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
}
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) RepoLatestVer(c *gin.Context) {
qp, err := utils.GetQueryProps(c)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
app := h.GetApp(c)
if app == nil {
return // sets error inside
}
rep, err := app.Repositories.Containing(qp.Name)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
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,80 +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]" {
func (h *HelmHandler) Install(c *gin.Context) { ns = ""
qp, err := utils.GetQueryProps(c, false)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
} }
rel, err := app.Releases.Install(ns, c.PostForm("name"), c.PostForm("chart"), c.PostForm("version"), justTemplate, values)
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 {
manifests, err = h.Data.RevisionManifests(qp.Namespace, qp.Name, 0, false)
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
out = subproc.GetDiff(strings.TrimSpace(manifests), out, "current.yaml", "upgraded.yaml")
} else { } else {
c.Header("Content-Type", "application/json") c.IndentedJSON(http.StatusAccepted, rel)
} }
c.String(http.StatusAccepted, out)
} }
func (h *HelmHandler) Tests(c *gin.Context) { func (h *HelmHandler) Upgrade(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
} }
out, err := h.Data.RunTests(qp.Namespace, qp.Name) existing, err := app.Releases.ByName(c.Param("ns"), c.Param("name"))
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return 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) c.String(http.StatusOK, out)
} }
func (h *HelmHandler) GetInfoSection(c *gin.Context) { func (h *HelmHandler) GetInfoSection(c *gin.Context) {
qp, err := utils.GetQueryProps(c, true) if c.Query("revision") != "" { // don't cache if latest is requested
if err != nil { h.EnableClientCache(c)
_ = c.AbortWithError(http.StatusBadRequest, err) }
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 return
} }
flag := c.Query("flag") == "true" rev, err := rel.GetRev(revn)
rDiff := c.Query("revisionDiff") if err != nil {
res, err := handleGetSection(h.Data, c.Param("section"), rDiff, qp, flag) _ = 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 {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
flag := c.Query("userDefined") == "true"
res, err := h.handleGetSection(rev, c.Param("section"), revDiff, flag)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -210,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
@@ -237,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
@@ -251,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]
@@ -263,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
err = s.detectClusterMode(data)
if err != nil {
return "", nil, err
}
go checkUpgrade(data.StatusInfo) go checkUpgrade(data.StatusInfo)
discoverScanners(&data) discoverScanners(data)
abort := make(utils.ControlChan) go data.PeriodicTasks(ctx)
api := NewRouter(abort, &data, s.Debug)
done := s.startBackgroundServer(api, abort)
return "http://" + s.Address, done api := NewRouter(cancel, data, s.Debug)
done := s.startBackgroundServer(api, ctx)
return "http://" + s.Address, done, nil
} }
func (s Server) startBackgroundServer(routes *gin.Engine, abort utils.ControlChan) utils.ControlChan { 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,53 +74,70 @@ 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) {
reportError("Failed to find chart in repo", xhr) $.getJSON("/api/helm/repositories/versions?name=" + elm.name).fail(function (xhr) {
}).done(function (vers) { reportError("Failed to find chart in repo", xhr)
// fill versions }).done(function (vers) {
$('#upgradeModal select').empty() // fill versions
for (let i = 0; i < vers.length; i++) { $('#upgradeModal select').empty()
const opt = $("<option value='" + vers[i].version + "'></option>"); for (let i = 0; i < vers.length; i++) {
if (vers[i].version === verCur) { const opt = $("<option value='" + vers[i].version + "'></option>");
opt.html(vers[i].version + " &middot;") if (vers[i].version === verCur) {
} else { opt.html(vers[i].version + " &middot;")
opt.html(vers[i].version) } else {
opt.html(vers[i].version)
}
$('#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)
}
}
const myModal = new bootstrap.Modal(document.getElementById('upgradeModal'), {}); function upgrPopUpCommon(verCur, ns, lastRev, name) {
myModal.show() const myModal = new bootstrap.Modal(document.getElementById('upgradeModal'), {});
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)
}) })
} 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");
reportError("Failed to get upgrade info", xhr) // TODO: if chart is empty, query different URL that will restore values without repo
}).done(function (data) { if (chart) {
data = hljs.highlight(data, {language: 'yaml'}).value $.get("/api/helm/repositories/values?chart=" + chart + "&version=" + self.val()).fail(function (xhr) {
$("#upgradeModal .ref-vals").html(data) reportError("Failed to get upgrade info", xhr)
}) }).done(function (data) {
data = hljs.highlight(data, {language: 'yaml'}).value
$("#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,36 +252,52 @@ function requestChangeDiff() {
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: "/api/helm/charts/install" + upgradeModalQstr(), url: upgradeModalURL(),
data: values, 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) {
diffBody.empty(); $('#upgradeModal').data("newManifest", data.manifest)
$("#upgradeModal .btn-confirm").prop("disabled", false)
const targetElement = document.getElementById('upgradeModalBody'); const form = new FormData();
const configuration = { form.append('a', $('#upgradeModal').data("curManifest"));
inputFormat: 'diff', outputFormat: 'side-by-side', form.append('b', data.manifest);
drawFileList: false, showFiles: false, highlight: true,
}; $.ajax({
const diff2htmlUi = new Diff2HtmlUI(targetElement, data, configuration); type: "POST",
diff2htmlUi.draw() url: "/diff",
if (!data) { processData: false,
diffBody.html("No changes will happen to the cluster") contentType: false,
} data: form,
}).fail(function (xhr) {
$("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: " + xhr.responseText + "</p>")
}).done(function (data) {
diffBody.empty();
$("#upgradeModal .btn-confirm").prop("disabled", false)
const targetElement = document.getElementById('upgradeModalBody');
const configuration = {
inputFormat: 'diff', outputFormat: 'side-by-side',
drawFileList: false, showFiles: false, highlight: true,
};
const diff2htmlUi = new Diff2HtmlUI(targetElement, data, configuration);
diff2htmlUi.draw()
if (!data) {
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)
@@ -345,16 +395,23 @@ $("#btnAddRepository").click(function () {
}) })
$("#btnTest").click(function() { $("#btnTest").click(function() {
$("#testModal .test-result").empty().prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') 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({ $.ajax({
type: 'POST', type: 'POST',
url: "/api/helm/charts/tests" + "?namespace=" + getHashParam("namespace") + "&name=" + getHashParam("chart") url: "/api/helm/releases/" + getHashParam("namespace") + "/" + getHashParam("chart") + "/test"
}).fail(function (xhr) { }).fail(function (xhr) {
reportError("Failed to execute test for chart", xhr) reportError("Failed to execute test for chart", xhr)
myModal.hide()
}).done(function (data) { }).done(function (data) {
$("#testModal .test-result").empty().html(data.replaceAll("\n", "<br>")) 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()
}) })
const myModal = new bootstrap.Modal(document.getElementById('testModal'), {});
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);
@@ -228,7 +226,7 @@ function getStatusMessage(status) {
} }
if (status.conditions) { if (status.conditions) {
return status.conditions[0].message || status.conditions[0].reason return status.conditions[0].message || status.conditions[0].reason
} }
return status.message || status.reason return status.message || status.reason
} }
@@ -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

@@ -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,7 +195,7 @@
<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" <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> title="Run tests for this chart"><i class="bi-check-circle"></i> <span>Run tests</span>
</button> </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"
@@ -367,14 +368,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):

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,43 +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) {
let match = null card.find(".rel-name").attr("style", "background-image: url(" + elm.icon + ")")
// semver2 regex , add optional v prefix
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-]+)*))?'
if (!new RegExp(chartNameRegex).test(chartName)) {
alert('Chart name does not match chart name regex.')
} else {
match = chartName.match(chartNameRegex);
} }
if (match) { if (elm.description) {
chartName = elm.chart.substring(0, match.index - 1) card.find(".rel-name div").text(elm.description)
} 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)
}
}
})
}
})
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,13 +2,13 @@ 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) {
const items = $("#sectionRepo .repo-list ul").empty() const items = $("#sectionRepo .repo-list ul").empty()
data.sort((a, b) => (a.name > b.name) - (a.name < b.name)) data.sort((a, b) => (a.name > b.name) - (a.name < b.name))
data.forEach(function (elm) { data.forEach(function (elm) {
let opt = $('<li class="mb-2"><label><input type="radio" name="cluster" class="me-2"/><span></span></label></li>'); let opt = $('<li class="mb-2"><label><input type="radio" name="cluster" class="me-2"/><span></span></label></li>');
opt.attr('title', elm.url) opt.attr('title', elm.url)
@@ -20,7 +20,7 @@ function loadRepoView() {
if (!data.length) { if (!data.length) {
items.text("No repositories found, try adding one") items.text("No repositories found, try adding one")
} }
sendStats('Get repo', {'status': 'success', length:data.length}); sendStats('Get repo', {'status': 'success', length: data.length});
items.find("input").click(function () { items.find("input").click(function () {
$("#inputSearch").val('') $("#inputSearch").val('')
const self = $(this) const self = $(this)
@@ -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 => {
const contextNamespace = ctxFiltered.length?ctxFiltered[0].Namespace:"" return obj.Name === getHashParam("context")
});
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

@@ -1,318 +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 (d *DataLayer) RunTests(namespace string, name string) (string, error) {
cmd := []string{"test", name, "--namespace", namespace, "--logs"}
out, err := d.runCommandHelm(cmd...)
if err != nil {
return "", err
}
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

@@ -10,34 +10,24 @@ import (
func TestGetQueryProps(t *testing.T) { func TestGetQueryProps(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
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", name: "Get query props - no namespace with revRequired true",
wantErr: true, wantErr: false,
revRequired: true, endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&revision=1",
endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&namespace=testing",
}, },
{ {
name: "Get query props - no namespace with revRequired true", name: "Get query props - no name with revRequired true",
wantErr: false, wantErr: true,
revRequired: true, endpoint: "/api/v1/namespaces/komodorio/charts?namespace=testing&revision=1",
endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&revision=1",
},
{
name: "Get query props - no name with revRequired true",
wantErr: true,
revRequired: true,
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