mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-26 14:28:04 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8faa5dabd5 | ||
|
|
0b891e7c28 | ||
|
|
2736777b8a | ||
|
|
96c789a012 | ||
|
|
e13aa2fde6 | ||
|
|
6ffcdf2b8e | ||
|
|
e6d6ff41a9 | ||
|
|
0e45e89a1e | ||
|
|
2df533ab5b | ||
|
|
7e32008bfe | ||
|
|
23cfd2d61b | ||
|
|
0e6231dfbd | ||
|
|
4ebf67095a | ||
|
|
5902bc010e | ||
|
|
36217401c8 | ||
|
|
e6381f96e9 | ||
|
|
efe394c8c4 | ||
|
|
a91287c2ff | ||
|
|
28a4b37bb5 | ||
|
|
2e1f2e481b | ||
|
|
714c0f9e02 | ||
|
|
35421ede58 | ||
|
|
26e0b1db32 | ||
|
|
50947e585d | ||
|
|
40ae829186 | ||
|
|
87ee388bfb | ||
|
|
b76fbb130a | ||
|
|
f3c66ecf03 | ||
|
|
83e4348ace | ||
|
|
af1c09ae02 | ||
|
|
6e5bce26e8 | ||
|
|
d0d2ed3ef1 | ||
|
|
450190a1aa | ||
|
|
74aab13e3e | ||
|
|
c5cd12b6b2 | ||
|
|
fd650f10b6 | ||
|
|
3e1e4be4b3 | ||
|
|
d0c9de9718 | ||
|
|
1a1b28d09f | ||
|
|
6c4ee06d27 | ||
|
|
7c5ffe9c07 | ||
|
|
196644683c | ||
|
|
20ee6e9695 | ||
|
|
e9abedbadb | ||
|
|
5d0a148fea | ||
|
|
c0cf6237e6 | ||
|
|
9876b6a12e | ||
|
|
90815f2271 | ||
|
|
e83ddbb15d | ||
|
|
78f458112b | ||
|
|
2c25193adf |
59
.github/ISSUE_TEMPLATE/bug.yaml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/bug.yaml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: 🐛 Bug
|
||||
|
||||
description: Report an issue to help improve the project.
|
||||
|
||||
labels: ["🛠 goal: fix"]
|
||||
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
|
||||
id: description
|
||||
|
||||
attributes:
|
||||
|
||||
label: Description
|
||||
|
||||
description: A brief description of the question or issue, also include what you tried and what didn't work
|
||||
|
||||
validations:
|
||||
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
|
||||
id: screenshots
|
||||
|
||||
attributes:
|
||||
|
||||
label: Screenshots
|
||||
|
||||
description: Please add screenshots if applicable
|
||||
|
||||
validations:
|
||||
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
|
||||
id: extrainfo
|
||||
|
||||
attributes:
|
||||
|
||||
label: Additional information
|
||||
|
||||
description: Is there anything else we should know about this bug?
|
||||
|
||||
validations:
|
||||
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
|
||||
attributes:
|
||||
|
||||
value: |
|
||||
|
||||
You can also join our slack community [here](https://komodorkommunity.slack.com)
|
||||
|
||||
Feel free to check out other cool repositories of the [komodorio](https://github.com/komodorio)
|
||||
68
.github/labeler.yml
vendored
Normal file
68
.github/labeler.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
# This configures label matching for PR's.
|
||||
#
|
||||
# The keys are labels, and the values are lists of minimatch patterns
|
||||
# to which those labels apply.
|
||||
#
|
||||
# NOTE: This can only add labels, not remove them.
|
||||
# NOTE: Due to YAML syntax limitations, patterns or labels which start
|
||||
# with a character that is part of the standard YAML syntax must be
|
||||
# quoted.
|
||||
#
|
||||
# Please keep the labels sorted and deduplicated.
|
||||
|
||||
api:
|
||||
- pkg/dashboard/api.go
|
||||
|
||||
app:
|
||||
- main.go
|
||||
- pkg/dashboard/server.go
|
||||
- pkg/dashboard/subproc/*
|
||||
- pkg/dashboard/utils/*
|
||||
|
||||
backend:
|
||||
- pkg/dashboard/handlers/*
|
||||
- pkg/dashboard/scanners/*
|
||||
|
||||
ci:
|
||||
- .github/workflow/build.yml
|
||||
- ci/*
|
||||
- Makefile
|
||||
- scripts/*
|
||||
|
||||
docs:
|
||||
- CODE_OF_CONDUCT.md
|
||||
- CONTRIBUTING.md
|
||||
- LICENSE
|
||||
- README.md
|
||||
- screenshot*.png
|
||||
- screenshot*.svg
|
||||
|
||||
docker:
|
||||
- .dockerignore
|
||||
- Dockerfile
|
||||
|
||||
helm-charts:
|
||||
- charts/*
|
||||
|
||||
github-actions:
|
||||
- .github/ISSUE_TEMPLATE/*
|
||||
- .github/labeler.yml
|
||||
- .github/pull_request_template
|
||||
- .github/workflow/pull-request-labeler.yaml
|
||||
|
||||
release:
|
||||
- .github/workflows/publish-chart.yaml
|
||||
- .github/workflows/release.yaml
|
||||
- .goreleaser.yml
|
||||
- artifacthub-repo.yml
|
||||
- plugin.yaml
|
||||
|
||||
scanners:
|
||||
- pkg/dashboard/scanners/*
|
||||
|
||||
tests:
|
||||
- pkg/dashboard/**/*_test.go
|
||||
- pkg/dashboard/objects/testdata/*
|
||||
|
||||
frontend:
|
||||
- pkg/dashboard/static/*
|
||||
35
.github/pull_request_template.md
vendored
Normal file
35
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
<!-- If your PR fixes an open issue, use `Closes #999` to link your PR with the issue. #999 stands for the issue number you are fixing -->
|
||||
|
||||
## Fixes Issue
|
||||
|
||||
<!-- Remove this section if not applicable -->
|
||||
|
||||
<!-- Example: Closes #31 -->
|
||||
|
||||
## Changes proposed
|
||||
|
||||
<!-- List all the proposed changes in your PR -->
|
||||
|
||||
<!-- Mark all the applicable boxes. To mark the box as done follow the following conventions -->
|
||||
<!--
|
||||
[x] - Correct; marked as done
|
||||
[X] - Correct; marked as done
|
||||
|
||||
[ ] - Not correct; marked as **not** done
|
||||
-->
|
||||
|
||||
## Check List (Check all the applicable boxes) <!-- Follow the above conventions to check the box -->
|
||||
|
||||
- [ ] My code follows the code style of this project.
|
||||
- [ ] My change requires changes to the documentation.
|
||||
- [ ] I have updated the documentation accordingly.
|
||||
- [ ] All new and existing tests passed.
|
||||
- [ ] The title of my pull request is a short description of the requested changes.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- Add all the screenshots which support your changes -->
|
||||
|
||||
## Note to reviewers
|
||||
|
||||
<!-- Add notes to reviewers if applicable -->
|
||||
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: main
|
||||
pull_request:
|
||||
branches: main
|
||||
branches: "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
go-version: 1.18
|
||||
- name: Unit tests
|
||||
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
|
||||
run: |
|
||||
go vet ./... # go vet is the official Go static analyzer
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Test Binary is Runnable
|
||||
run: "dist/helm-dashboard_linux_amd64_v1/helm-dashboard --help"
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3.2.0
|
||||
uses: golangci/golangci-lint-action@v3.3.1
|
||||
with:
|
||||
# version: latest
|
||||
# skip-go-installation: true
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Helm Template Check For Sanity
|
||||
@@ -68,3 +68,6 @@ jobs:
|
||||
env:
|
||||
CHART_LOCATION: ./charts/helm-dashboard
|
||||
CHART_VALUES: ./charts/helm-dashboard/values.yaml
|
||||
- name: Test Helm plugin install script is runnable
|
||||
run: |
|
||||
scripts/install_plugin.sh
|
||||
|
||||
2
.github/workflows/publish-chart.yaml
vendored
2
.github/workflows/publish-chart.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Bump versions
|
||||
|
||||
14
.github/workflows/pull-request-labeler.yaml
vendored
Normal file
14
.github/workflows/pull-request-labeler.yaml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
21
.github/workflows/release.yaml
vendored
21
.github/workflows/release.yaml
vendored
@@ -18,26 +18,16 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get plugin version
|
||||
id: get_plugin_version
|
||||
run: echo "PLUGIN_VERSION=$(cat plugin.yaml | grep "version" | cut -d '"' -f 2)" >> $GITHUB_OUTPUT
|
||||
- name: Get tag name
|
||||
id: get_tag_name
|
||||
run: echo "TAG_NAME=$(echo ${{ github.ref_name }} | cut -d 'v' -f2)" >> $GITHUB_OUTPUT
|
||||
outputs:
|
||||
plugin_version: ${{ steps.get_plugin_version.outputs.PLUGIN_VERSION }}
|
||||
release_tag: ${{ steps.get_tag_name.outputs.TAG_NAME }}
|
||||
|
||||
release:
|
||||
needs: pre_release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Plugin version/Tag name Check
|
||||
if: needs.pre_release.outputs.release_tag != needs.pre_release.outputs.plugin_version
|
||||
uses: actions/github-script@v3
|
||||
with:
|
||||
script: |
|
||||
core.setFailed('Plugin version and tag name are not equivalent!')
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
@@ -60,11 +50,11 @@ jobs:
|
||||
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
needs: [release, pre_release]
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker meta
|
||||
uses: docker/metadata-action@v3
|
||||
@@ -85,17 +75,17 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ needs.pre_release.outputs.release_tag }}
|
||||
tags: komodorio/helm-dashboard:${{ needs.pre_release.outputs.release_tag }},komodorio/helm-dashboard:latest
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: VER=${{ needs.pre_release.outputs.release_tag }}
|
||||
|
||||
publish_chart:
|
||||
runs-on: ubuntu-latest
|
||||
need: image
|
||||
needs: [image, pre_release]
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Bump versions
|
||||
@@ -106,6 +96,7 @@ jobs:
|
||||
git checkout main
|
||||
sh ./ci/bump-versions.sh
|
||||
git add charts/helm-dashboard/Chart.yaml
|
||||
git add plugin.yaml
|
||||
git commit -m "Increment chart versions [skip ci]" || echo "Already up-to-date"
|
||||
git push -f || echo "Nothing to push!"
|
||||
env:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
*.cov
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
55
CONTRIBUTING.md
Normal file
55
CONTRIBUTING.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Contributing to Helm Dashboard
|
||||
|
||||
We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
|
||||
|
||||
- Reporting a bug
|
||||
- Discussing the current state of the code
|
||||
- Submitting a fix
|
||||
- Proposing new features
|
||||
|
||||
## We Develop with GitHub
|
||||
|
||||
We use GitHub to host code, to track issues and feature requests, as well as accept pull requests.
|
||||
|
||||
## First-time contributors
|
||||
|
||||
We've tagged some issues to make it easy to get started :smile:
|
||||
[Good first issues](https://github.com/komodorio/helm-dashboard/labels/good%20first%20issue)
|
||||
|
||||
Add a comment on the issue and wait for the issue to be assigned before you start working on it. This helps to avoid multiple people working on similar issues.
|
||||
|
||||
## All Code Changes Happen Through Pull Requests
|
||||
|
||||
Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests:
|
||||
|
||||
1. Fork the repo and create your branch from `main`.
|
||||
2. If you've added code that should be tested, add tests.
|
||||
3. Ensure the test suite passes.
|
||||
4. Make sure your code lints.
|
||||
5. Issue that pull request!
|
||||
|
||||
## Any contributions you make will be under the Apache License 2.0
|
||||
|
||||
In short, when you submit code changes, your submissions are understood to be under the same [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) that covers the project.
|
||||
|
||||
## Report bugs using GitHub's [issues](https://github.com/komodorio/helm-dashboard/issues)
|
||||
|
||||
We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/komodorio/helm-dashboard/issues/new) and labeling it with the `bug` label. It's that easy!
|
||||
|
||||
**Great Bug Reports** tend to have:
|
||||
|
||||
- A quick summary and/or background
|
||||
- Steps to reproduce
|
||||
- Be specific!
|
||||
- Give sample code if you can.
|
||||
- What you expected would happen
|
||||
- What actually happens
|
||||
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under its Apache License 2.0.
|
||||
|
||||
## Questions?
|
||||
|
||||
Contact us on [Slack](https://komodorkommunity.slack.com).
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,12 +1,10 @@
|
||||
# Stage - builder
|
||||
FROM golang as builder
|
||||
|
||||
ARG VER
|
||||
|
||||
ENV GOOS=linux
|
||||
ENV GOARCH=amd64
|
||||
ENV CGO_ENABLED=0
|
||||
ENV VERSION=0.0.0
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -15,6 +13,9 @@ COPY go.sum ./
|
||||
COPY main.go ./
|
||||
RUN go mod download
|
||||
|
||||
ARG VER=0.0.0
|
||||
ENV VERSION=${VER}
|
||||
|
||||
ADD . src
|
||||
|
||||
WORKDIR /build/src
|
||||
@@ -22,20 +23,21 @@ WORKDIR /build/src
|
||||
RUN make build
|
||||
|
||||
# Stage - runner
|
||||
FROM alpine/helm
|
||||
FROM alpine
|
||||
EXPOSE 8080
|
||||
|
||||
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
|
||||
|
||||
# Checkov scanner
|
||||
RUN apk add --update --no-cache python3
|
||||
RUN python3 -m ensurepip
|
||||
RUN pip3 install checkov
|
||||
# Python
|
||||
RUN apk add --update --no-cache python3 curl && python3 -m ensurepip && pip3 install --upgrade pip setuptools
|
||||
|
||||
# Trivy
|
||||
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.18.3
|
||||
RUN trivy --version
|
||||
|
||||
# Checkov scanner
|
||||
RUN pip3 install checkov packaging==21.3 && checkov --version
|
||||
|
||||
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
|
||||
2
Makefile
2
Makefile
@@ -4,7 +4,7 @@ VERSION ?= $(git describe --tags --always --dirty --match=v* 2> /dev/null || \
|
||||
|
||||
.PHONY: test
|
||||
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 "** TOTAL COVERAGE: $$(go tool cover -func profile.cov | grep total | grep -Eo '[0-9]+\.[0-9]+')% **"
|
||||
@echo "*****************************\n"
|
||||
|
||||
54
README.md
54
README.md
@@ -1,19 +1,26 @@
|
||||
#  
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="pkg/dashboard/static/logo-header-inverted.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="pkg/dashboard/static/logo-header.svg#gh-light-mode-only">
|
||||
<img alt="Helm Dashboard" src="pkg/dashboard/static/logo-header.svg#gh-light-mode-only">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
A simplified way of working with Helm.
|
||||
<p align="center">A simplified way of working with Helm.</p>
|
||||
|
||||
 [](https://github.com/komodorio/helm-dashboard/issues)      [](https://github.com/komodorio/helm-dashboard)
|
||||
|
||||
<kbd>[<img src="screenshot.png" style="width: 100%; border: 1px solid silver;" border="1" alt="Screenshot">](screenshot.png)</kbd>
|
||||
|
||||
## What it Does?
|
||||
## Description
|
||||
|
||||
_Helm Dashboard_ offers a UI-driven way to view the installed Helm charts, see their revision history and
|
||||
_Helm Dashboard_ is an **open-source project** which offers a UI-driven way to view the installed Helm charts, see their revision history and
|
||||
corresponding k8s resources. Also, you can perform simple actions like roll back to a revision or upgrade to newer
|
||||
version.
|
||||
|
||||
This project is part of [Komodor's](https://komodor.com/?utm_campaign=Helm-Dash&utm_source=helm-dash-gh) vision of
|
||||
helping Kubernetes users to navigate and troubleshoot their clusters, the project is **NOT** an offical project by the [helm team](https://helm.sh/).
|
||||
|
||||
Some of the key capabilities of the tool:
|
||||
Key capabilities of the tool:
|
||||
|
||||
- See all installed charts and their revision history
|
||||
- See manifest diff of the past revisions
|
||||
@@ -21,12 +28,20 @@ Some of the key capabilities of the tool:
|
||||
- Easy rollback or upgrade version with a clear and easy manifest diff
|
||||
- Integration with popular problem scanners
|
||||
- Easy switch between multiple clusters
|
||||
- Can be used locally, or installed into Kubernetes cluster
|
||||
- Does not require Helm or Kubectl installed
|
||||
|
||||
## 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
|
||||
|
||||
To install the plugin, simply run Helm command:
|
||||
To install dashboard as Helm plugin, simply run Helm command:
|
||||
|
||||
```shell
|
||||
helm plugin install https://github.com/komodorio/helm-dashboard.git
|
||||
@@ -65,7 +80,7 @@ This can also be specified using flag `--bind <host>`, for example `--bind=0.0.0
|
||||
|
||||
If your port 8080 is busy, you can specify a different port to use via `--port <number>` command-line flag.
|
||||
|
||||
If 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.
|
||||
|
||||
@@ -77,9 +92,16 @@ If you want to increase the logging verbosity and see all the debug info, use th
|
||||
|
||||
The official helm chart is [available here](https://github.com/komodorio/helm-charts/blob/master/charts/helm-dashboard)
|
||||
|
||||
### Manual Installation
|
||||
|
||||
Download the appropriate [release package](https://github.com/komodorio/helm-dashboard/releases) for your platform, unpack it and just run `dashboard` binary from it.
|
||||
## Execute Helm tests
|
||||
|
||||
For all the release(s) (installed helm charts), you can execute helm tests for that release. For the tests to execute successfully, you need to have existing tests for that helm chart.
|
||||
|
||||
You can execute `helm test` for the specific release as below:
|
||||

|
||||
|
||||
The result of executed `helm test` for the release will be disapled as below:
|
||||

|
||||
|
||||
## Scanner Integrations
|
||||
|
||||
@@ -97,9 +119,19 @@ button at the bottom of the dialog:
|
||||
## Support Channels
|
||||
|
||||
We have two main channels for supporting the Helm Dashboard
|
||||
users: [Slack community](https://join.slack.com/t/komodorkommunity/shared_invite/zt-1dm3cnkue-ov1Yh~_95teA35QNx5yuMg) for general conversations
|
||||
users: [Slack community](https://komodorkommunity.slack.com) for general conversations
|
||||
and [GitHub issues](https://github.com/komodorio/helm-dashboard/issues) for real bugs.
|
||||
|
||||
## Contributing
|
||||
|
||||
Kindly read our [Contributing Guide](CONTRIBUTING.md) to learn and understand about our development process, how to propose bug fixes and improvements, and how to build and test your changes to Helm Dashboard. <br>
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/komodorio/helm-dashboard/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=komodorio/helm-dashboard" />
|
||||
</a>
|
||||
|
||||
## Local Dev Testing
|
||||
|
||||
Prerequisites: `helm` and `kubectl` binaries installed and operational.
|
||||
|
||||
@@ -3,7 +3,7 @@ type: application
|
||||
|
||||
name: helm-dashboard
|
||||
description: A GUI Dashboard for Helm by Komodor
|
||||
icon: "https://github.com/komodorio/helm-dashboard/blob/main/pkg/dashboard/static/logo.png"
|
||||
icon: "https://raw.githubusercontent.com/komodorio/helm-dashboard/main/pkg/dashboard/static/logo.svg"
|
||||
|
||||
version: 0.1.0
|
||||
appVersion: "0.0.0"
|
||||
version: 0.1.2
|
||||
appVersion: "0.3.1"
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
- Kubernetes 1.16+
|
||||
@@ -39,6 +41,12 @@ helm uninstall my-release
|
||||
|
||||
The command removes all the Kubernetes components associated with the chart and deletes the release.
|
||||
|
||||
## Adding Authentication
|
||||
|
||||
The task of authentication and user control is out of scope for Helm Dashboard. Luckily, there are third-party solutions which are dedicated to provide that functionality.
|
||||
|
||||
For instance, you can place authentication proxy in front of Helm Dashboard, like this one: https://github.com/oauth2-proxy/oauth2-proxy
|
||||
|
||||
## Parameters
|
||||
|
||||
The following table lists the configurable parameters of the chart and their default values.
|
||||
@@ -49,7 +57,7 @@ The following table lists the configurable parameters of the chart and their def
|
||||
| `image.tag` | Image tag | |
|
||||
| `image.pullPolicy` | Image pull policy | `IfNotPresent` |
|
||||
| `replicaCount` | Number of dashboard Pods to run | `1` |
|
||||
| `dashboard.allowWriteActions` | Enables write actions. Allow modifying, deleting and creating charts and kubernetes resources. | `false` |
|
||||
| `dashboard.allowWriteActions` | Enables write actions. Allow modifying, deleting and creating charts and kubernetes resources. | `true` |
|
||||
| `resources.requests.cpu` | CPU resource requests | `200m` |
|
||||
| `resources.limits.cpu` | CPU resource limits | `1` |
|
||||
| `resources.requests.memory` | Memory resource requests | `256Mi` |
|
||||
@@ -65,7 +73,9 @@ The following table lists the configurable parameters of the chart and their def
|
||||
| `dashboard.persistence.accessModes` | Persistent Volume access modes | `["ReadWriteOnce"]` |
|
||||
| `dashboard.persistence.storageClass` | Persistent Volume storage class | `""` |
|
||||
| `dashboard.persistence.size` | Persistent Volume size | `100M` |
|
||||
| `dashboard.persistence.hostPath` | Set path in case you want to use local host path volumes (not recommended in production) | `""` |
|
||||
| `dashboard.persistence.hostPath` | Set path in case you want to use local host path volumes (not recommended in production) | `""`
|
||||
| `updateStrategy.type` | Set up update strategy for helm-dashboard installation. | `RollingUpdate` |
|
||||
| `extraArgs` | Set the arguments to be supplied to the helm-dashboard binary | `[--no-browser, --bind=0.0.0.0]`
|
||||
|
||||
Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "helm-dashboard.selectorLabels" . | nindent 6 }}
|
||||
strategy: {{- toYaml .Values.updateStrategy | nindent 4 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
@@ -29,6 +30,12 @@ spec:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
command:
|
||||
- /bin/helm-dashboard
|
||||
args:
|
||||
{{- with .Values.extraArgs }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
@@ -40,6 +47,12 @@ spec:
|
||||
value: /opt/dashboard/helm/config
|
||||
- name: HELM_DATA_HOME
|
||||
value: /opt/dashboard/helm/data
|
||||
- name: DEBUG
|
||||
value: {{- ternary " 1" "" .Values.debug }}
|
||||
{{- if .Values.dashboard.namespace }}
|
||||
- name: HELM_NAMESPACE
|
||||
value: {{ .Values.dashboard.namespace }}
|
||||
{{end}}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
@@ -77,3 +90,4 @@ spec:
|
||||
{{- else }}
|
||||
emptyDir: { }
|
||||
{{- end }}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ metadata:
|
||||
spec:
|
||||
{{- if .Values.dashboard.persistence.hostPath }}
|
||||
storageClassName: ""
|
||||
{{- else }}
|
||||
storageClassName: "{{ .Values.dashboard.persistence.storageClass }}"
|
||||
{{- end }}
|
||||
accessModes:
|
||||
{{- if not (empty .Values.dashboard.persistence.accessModes) }}
|
||||
|
||||
@@ -17,13 +17,12 @@ apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: {{ include "helm-dashboard.serviceAccountName" . }}
|
||||
rules:
|
||||
- apiGroups: ["", "apps", "rbac.authorization.k8s.io", "rbac", "batch", "extensions", "networking.k8s.io", "storage.k8s.io"]
|
||||
- apiGroups: ["*"]
|
||||
resources: ["*"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
{{- if .Values.dashboard.allowWriteActions }}
|
||||
- apiGroups: ["", "apps", "rbac.authorization.k8s.io", "rbac", "batch", "extensions", "networking.k8s.io", "storage.k8s.io"]
|
||||
resources: ["*"]
|
||||
verbs: ["get", "list", "watch", "create", "delete", "patch", "update"]
|
||||
{{- else }}
|
||||
verbs: ["get", "list", "watch"]
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
replicaCount: 1
|
||||
|
||||
# Flag for setting environment to debug mode
|
||||
debug: false
|
||||
|
||||
image:
|
||||
repository: komodorio/helm-dashboard
|
||||
pullPolicy: IfNotPresent
|
||||
@@ -26,7 +29,11 @@ resources:
|
||||
memory: 1Gi
|
||||
|
||||
dashboard:
|
||||
allowWriteActions: false
|
||||
allowWriteActions: true
|
||||
|
||||
# default namespace for Helm operations
|
||||
namespace: ""
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
|
||||
@@ -61,6 +68,20 @@ dashboard:
|
||||
##
|
||||
size: 100M
|
||||
|
||||
## @param.updateStrategy.type Set up update strategy for helm-dashboard installation.
|
||||
## Set to Recreate if you use persistent volume that cannot be mounted by more than one pods to make sure the pods is destroyed first.
|
||||
## ref: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy
|
||||
## Example:
|
||||
## updateStrategy:
|
||||
## type: RollingUpdate
|
||||
## rollingUpdate:
|
||||
## maxSurge: 25%
|
||||
## maxUnavailable: 25%
|
||||
##
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext: {}
|
||||
@@ -90,6 +111,11 @@ autoscaling:
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
extraArgs:
|
||||
- --no-browser
|
||||
- --bind=0.0.0.0
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/bin/bash -e
|
||||
|
||||
WORKING_DIRECTORY="$PWD"
|
||||
|
||||
@@ -9,6 +9,7 @@ WORKING_DIRECTORY="$PWD"
|
||||
}
|
||||
|
||||
sed -i -e "s/appVersion.*/appVersion: \"${APP_VERSION}\" /g" ${HELM_CHARTS_SOURCE}/Chart.yaml
|
||||
sed -i -e "s/version.*/version: \"${APP_VERSION}\" /g" plugin.yaml
|
||||
CURRENT_VERSION=$(cat ${HELM_CHARTS_SOURCE}/Chart.yaml | grep 'version:' | awk '{print $2}')
|
||||
NEW_VERSION=$(echo $CURRENT_VERSION | awk -F. '{$NF = $NF + 1;} 1' | sed 's/ /./g')
|
||||
sed -i -e "s/${CURRENT_VERSION}/${NEW_VERSION}/g" ${HELM_CHARTS_SOURCE}/Chart.yaml
|
||||
|
||||
127
go.mod
127
go.mod
@@ -3,68 +3,165 @@ module github.com/komodorio/helm-dashboard
|
||||
go 1.18
|
||||
|
||||
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/hashicorp/go-version v1.6.0
|
||||
github.com/hexops/gotextdiff v1.0.3
|
||||
github.com/jessevdk/go-flags v1.5.0
|
||||
github.com/joomcode/errorx v1.1.0
|
||||
github.com/olekukonko/tablewriter v0.0.5
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
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/stretchr/testify v1.8.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
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 (
|
||||
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/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/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // 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/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/chai2010/gettext-go v1.0.2 // indirect
|
||||
github.com/containerd/containerd v1.6.12 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.2.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/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/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-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/universal-translator v0.18.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.11.0 // 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/gogo/protobuf v1.3.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/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/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/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-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/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/pelletier/go-toml/v2 v2.0.3 // indirect
|
||||
github.com/prometheus/client_golang v1.12.2 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.33.0 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.14.0 // indirect
|
||||
github.com/prometheus/client_model v0.3.0 // indirect
|
||||
github.com/prometheus/common v0.37.0 // indirect
|
||||
github.com/prometheus/procfs v0.8.0 // indirect
|
||||
github.com/rubenv/sql-migrate v1.1.2 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/shopspring/decimal v1.2.0 // indirect
|
||||
github.com/spf13/cast v1.5.0 // indirect
|
||||
github.com/spf13/cobra v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/ugorji/go/codec v1.2.7 // 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/exp v0.0.0-20220518171630-0b5c67f07fdf // indirect
|
||||
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c // indirect
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
|
||||
golang.org/x/sys v0.0.0-20220818161305-2296e01440c6 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/exp v0.0.0-20221110155412-d0897a79cd37 // indirect
|
||||
golang.org/x/net v0.3.1-0.20221206200815-1e63c2f08a10 // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect
|
||||
golang.org/x/sync v0.1.0 // 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/genproto v0.0.0-20220502173005-c8bf987b8c21 // indirect
|
||||
google.golang.org/grpc v1.49.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
||||
gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
k8s.io/klog/v2 v2.70.1 // indirect
|
||||
k8s.io/utils v0.0.0-20220728103510-ee6ede2d64ed // indirect
|
||||
gotest.tools/v3 v3.4.0
|
||||
k8s.io/apiextensions-apiserver v0.25.2 // indirect
|
||||
k8s.io/apiserver v0.25.2 // indirect
|
||||
k8s.io/component-base v0.26.0 // indirect
|
||||
k8s.io/klog/v2 v2.80.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 // indirect
|
||||
k8s.io/utils v0.0.0-20221107191617-1a15be271d1d
|
||||
oras.land/oras-go v1.2.0 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect
|
||||
sigs.k8s.io/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/yaml v1.3.0 // indirect
|
||||
)
|
||||
|
||||
31
main.go
31
main.go
@@ -1,8 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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"`
|
||||
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
|
||||
Port uint `short:"p" long:"port" description:"Port to start server on" default:"8080"` // TODO: better default port to clash less?
|
||||
Namespace string `short:"n" long:"namespace" description:"Limit operations to a specific namespace"`
|
||||
Port uint `short:"p" long:"port" description:"Port to start server on" default:"8080"`
|
||||
Namespace string `short:"n" long:"namespace" description:"Namespace for HELM operations"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -42,12 +46,26 @@ func main() {
|
||||
|
||||
server := dashboard.Server{
|
||||
Version: version,
|
||||
Namespace: opts.Namespace,
|
||||
Namespaces: strings.Split(opts.Namespace, ","),
|
||||
Address: fmt.Sprintf("%s:%d", opts.BindHost, opts.Port),
|
||||
Debug: opts.Verbose,
|
||||
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 {
|
||||
log.Infof("User analytics is collected to improve the quality, disable it with --no-analytics")
|
||||
@@ -69,7 +87,7 @@ func main() {
|
||||
|
||||
func parseFlags() options {
|
||||
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 = ""
|
||||
}
|
||||
|
||||
@@ -92,7 +110,8 @@ func parseFlags() options {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"os"
|
||||
"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/*
|
||||
var staticFS embed.FS
|
||||
|
||||
func noCache(c *gin.Context) {
|
||||
if c.GetHeader("Cache-Control") == "" { // default policy is not to cache
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -26,33 +28,39 @@ func errorHandler(c *gin.Context) {
|
||||
|
||||
errs := ""
|
||||
for _, err := range c.Errors {
|
||||
log.Debugf("Error: %s", err)
|
||||
log.Debugf("Error: %+v", err)
|
||||
errs += err.Error() + "\n"
|
||||
}
|
||||
|
||||
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) {
|
||||
ctxName := ""
|
||||
if ctx, ok := c.Request.Header["X-Kubecontext"]; ok {
|
||||
log.Debugf("Setting current context to: %s", ctx)
|
||||
if data.KubeContext != ctx[0] {
|
||||
err := data.Cache.Clear()
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
ctxName = ctx[0]
|
||||
if err := data.SetContext(ctxName); err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if debug {
|
||||
api = gin.New()
|
||||
@@ -71,10 +79,10 @@ func NewRouter(abortWeb utils.ControlChan, data *subproc.DataLayer, debug bool)
|
||||
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
|
||||
api.DELETE("/", func(c *gin.Context) {
|
||||
abortWeb <- struct{}{}
|
||||
abortWeb()
|
||||
c.Status(http.StatusAccepted)
|
||||
})
|
||||
|
||||
@@ -83,11 +91,11 @@ func configureRoutes(abortWeb utils.ControlChan, data *subproc.DataLayer, api *g
|
||||
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)
|
||||
})
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
@@ -96,39 +104,63 @@ func configureRoutes(abortWeb utils.ControlChan, data *subproc.DataLayer, api *g
|
||||
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)
|
||||
configureKubectls(api.Group("/api/kube"), data)
|
||||
configureKubectls(api.Group("/api/k8s"), data)
|
||||
configureScanners(api.Group("/api/scanners"), data)
|
||||
}
|
||||
|
||||
func configureHelms(api *gin.RouterGroup, data *subproc.DataLayer) {
|
||||
h := handlers.HelmHandler{Data: data}
|
||||
|
||||
api.GET("/charts", h.GetCharts)
|
||||
api.DELETE("/charts", h.Uninstall)
|
||||
|
||||
api.GET("/charts/history", h.History)
|
||||
api.GET("/charts/resources", h.Resources)
|
||||
api.GET("/charts/:section", h.GetInfoSection)
|
||||
api.GET("/charts/show", h.Show)
|
||||
api.POST("/charts/install", h.Install)
|
||||
api.POST("/charts/rollback", h.Rollback)
|
||||
|
||||
api.GET("/repo", h.RepoList)
|
||||
api.POST("/repo", h.RepoAdd)
|
||||
api.DELETE("/repo", h.RepoDelete)
|
||||
api.GET("/repo/charts", h.RepoCharts)
|
||||
api.GET("/repo/search", h.RepoSearch)
|
||||
api.POST("/repo/update", h.RepoUpdate)
|
||||
api.GET("/repo/values", h.RepoValues)
|
||||
func configureHelms(api *gin.RouterGroup, data *objects.DataLayer) {
|
||||
h := handlers.HelmHandler{
|
||||
Contexted: &handlers.Contexted{
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
|
||||
func configureKubectls(api *gin.RouterGroup, data *subproc.DataLayer) {
|
||||
h := handlers.KubeHandler{Data: data}
|
||||
rels := api.Group("/releases")
|
||||
rels.GET("", h.GetReleases)
|
||||
rels.POST(":ns", h.Install)
|
||||
rels.POST(":ns/:name", h.Upgrade)
|
||||
rels.DELETE(":ns/:name", h.Uninstall)
|
||||
rels.GET(":ns/:name/history", h.History)
|
||||
rels.GET(":ns/:name/:section", h.GetInfoSection)
|
||||
rels.GET(":ns/:name/resources", h.Resources)
|
||||
rels.POST(":ns/:name/rollback", h.Rollback)
|
||||
rels.POST(":ns/:name/test", h.RunTests)
|
||||
|
||||
repos := api.Group("/repositories")
|
||||
repos.GET("", h.RepoList)
|
||||
repos.POST("", h.RepoAdd)
|
||||
repos.GET("/:name", h.RepoCharts)
|
||||
repos.POST("/:name", h.RepoUpdate)
|
||||
repos.DELETE("/:name", h.RepoDelete)
|
||||
repos.GET("/latestver", h.RepoLatestVer) // TODO: use /versions in client insted and remove this?
|
||||
repos.GET("/versions", h.RepoVersions)
|
||||
repos.GET("/values", h.RepoValues)
|
||||
}
|
||||
|
||||
func configureKubectls(api *gin.RouterGroup, data *objects.DataLayer) {
|
||||
h := handlers.KubeHandler{
|
||||
Contexted: &handlers.Contexted{
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
api.GET("/contexts", h.GetContexts)
|
||||
api.GET("/resources/:kind", h.GetResourceInfo)
|
||||
api.GET("/describe/:kind", h.Describe)
|
||||
api.GET("/namespaces", h.GetNameSpaces)
|
||||
api.GET("/:kind/get", h.GetResourceInfo)
|
||||
api.GET("/:kind/describe", h.Describe)
|
||||
api.GET("/:kind/list", h.GetNameSpaces)
|
||||
}
|
||||
|
||||
func configureStatic(api *gin.Engine) {
|
||||
@@ -161,9 +193,13 @@ func configureStatic(api *gin.Engine) {
|
||||
}
|
||||
}
|
||||
|
||||
func configureScanners(api *gin.RouterGroup, data *subproc.DataLayer) {
|
||||
h := handlers.ScannersHandler{Data: data}
|
||||
func configureScanners(api *gin.RouterGroup, data *objects.DataLayer) {
|
||||
h := handlers.ScannersHandler{
|
||||
Contexted: &handlers.Contexted{
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
api.GET("", h.List)
|
||||
api.POST("/manifests", h.ScanDraftManifest)
|
||||
api.POST("/manifests", h.ScanManifest)
|
||||
api.GET("/resource/:kind", h.ScanResource)
|
||||
}
|
||||
|
||||
412
pkg/dashboard/api_test.go
Normal file
412
pkg/dashboard/api_test.go
Normal 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
|
||||
}
|
||||
31
pkg/dashboard/handlers/common.go
Normal file
31
pkg/dashboard/handlers/common.go
Normal 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")
|
||||
}
|
||||
@@ -2,35 +2,72 @@ package handlers
|
||||
|
||||
import (
|
||||
"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"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
|
||||
)
|
||||
|
||||
type HelmHandler struct {
|
||||
Data *subproc.DataLayer
|
||||
*Contexted
|
||||
}
|
||||
|
||||
func (h *HelmHandler) GetCharts(c *gin.Context) {
|
||||
res, err := h.Data.ListInstalled()
|
||||
func (h *HelmHandler) getRelease(c *gin.Context) *objects.Release {
|
||||
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 {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
res := []*ReleaseElement{}
|
||||
for _, r := range rels {
|
||||
res = append(res, HReleaseToJSON(r.Orig))
|
||||
}
|
||||
|
||||
c.IndentedJSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (h *HelmHandler) Uninstall(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
rel := h.getRelease(c)
|
||||
if rel == nil {
|
||||
return // error state is set inside
|
||||
}
|
||||
err = h.Data.ReleaseUninstall(qp.Namespace, qp.Name)
|
||||
|
||||
err := rel.Uninstall()
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -39,13 +76,18 @@ func (h *HelmHandler) Uninstall(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 {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.Data.Rollback(qp.Namespace, qp.Name, qp.Revision)
|
||||
err = rel.Rollback(revn)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -54,73 +96,178 @@ func (h *HelmHandler) Rollback(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *HelmHandler) History(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
rel := h.getRelease(c)
|
||||
if rel == nil {
|
||||
return // error state is set inside
|
||||
}
|
||||
|
||||
res, err := h.Data.ReleaseHistory(qp.Namespace, qp.Name)
|
||||
revs, err := rel.History()
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *HelmHandler) Resources(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, true)
|
||||
h.EnableClientCache(c)
|
||||
|
||||
rel := h.getRelease(c)
|
||||
if rel == nil {
|
||||
return // error state is set inside
|
||||
}
|
||||
|
||||
res, err := objects.ParseManifests(rel.Orig.Manifest)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.IndentedJSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (h *HelmHandler) RepoVersions(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.Data.RevisionManifestsParsed(qp.Namespace, qp.Name, qp.Revision)
|
||||
app := h.GetApp(c)
|
||||
if app == nil {
|
||||
return // sets error inside
|
||||
}
|
||||
|
||||
repos, err := app.Repositories.Containing(qp.Name)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *HelmHandler) RepoSearch(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
func (h *HelmHandler) RepoLatestVer(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.Data.ChartRepoVersions(qp.Name)
|
||||
app := h.GetApp(c)
|
||||
if app == nil {
|
||||
return // sets error inside
|
||||
}
|
||||
|
||||
rep, err := app.Repositories.Containing(qp.Name)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.IndentedJSON(http.StatusOK, res)
|
||||
|
||||
res := []*RepoChartElement{}
|
||||
for _, r := range rep {
|
||||
res = append(res, &RepoChartElement{
|
||||
Name: r.Name,
|
||||
Version: r.Version,
|
||||
AppVersion: r.AppVersion,
|
||||
Description: r.Description,
|
||||
Repository: r.Annotations[objects.AnnRepo],
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(res, func(i, j int) bool {
|
||||
return semver.Compare(res[i].Version, res[j].Version) > 0
|
||||
})
|
||||
|
||||
if len(res) > 0 {
|
||||
c.IndentedJSON(http.StatusOK, res[:1])
|
||||
} else {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HelmHandler) RepoCharts(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
app := h.GetApp(c)
|
||||
if app == nil {
|
||||
return // sets error inside
|
||||
}
|
||||
|
||||
res, err := h.Data.ChartRepoCharts(qp.Name)
|
||||
rep, err := app.Repositories.Get(c.Param("name"))
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.IndentedJSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (h *HelmHandler) RepoUpdate(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
charts, err := rep.Charts()
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
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 {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -128,64 +275,125 @@ func (h *HelmHandler) RepoUpdate(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *HelmHandler) Show(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
func (h *HelmHandler) Install(c *gin.Context) {
|
||||
app := h.GetApp(c)
|
||||
if app == nil {
|
||||
return // sets error inside
|
||||
}
|
||||
|
||||
res, err := h.Data.ShowChart(qp.Name)
|
||||
values := map[string]interface{}{}
|
||||
err := yaml.Unmarshal([]byte(c.PostForm("values")), &values)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.IndentedJSON(http.StatusOK, res)
|
||||
justTemplate := c.PostForm("preview") == "true"
|
||||
ns := c.Param("ns")
|
||||
if ns == "[empty]" {
|
||||
ns = ""
|
||||
}
|
||||
|
||||
func (h *HelmHandler) Install(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
justTemplate := c.Query("flag") != "true"
|
||||
isInitial := c.Query("initial") != "true"
|
||||
out, err := h.Data.ChartInstall(qp.Namespace, qp.Name, c.Query("chart"), c.Query("version"), justTemplate, c.PostForm("values"), isInitial)
|
||||
rel, err := app.Releases.Install(ns, c.PostForm("name"), c.PostForm("chart"), c.PostForm("version"), justTemplate, values)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if justTemplate {
|
||||
manifests := ""
|
||||
if isInitial {
|
||||
manifests, err = h.Data.RevisionManifests(qp.Namespace, qp.Name, 0, false)
|
||||
c.IndentedJSON(http.StatusOK, rel)
|
||||
} else {
|
||||
c.IndentedJSON(http.StatusAccepted, rel)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HelmHandler) Upgrade(c *gin.Context) {
|
||||
app := h.GetApp(c)
|
||||
if app == nil {
|
||||
return // sets error inside
|
||||
}
|
||||
|
||||
existing, err := app.Releases.ByName(c.Param("ns"), c.Param("name"))
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
values := map[string]interface{}{}
|
||||
err = yaml.Unmarshal([]byte(c.PostForm("values")), &values)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
justTemplate := c.PostForm("preview") == "true"
|
||||
rel, err := existing.Upgrade(c.PostForm("chart"), c.PostForm("version"), justTemplate, values)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if justTemplate {
|
||||
c.IndentedJSON(http.StatusOK, rel)
|
||||
} else {
|
||||
c.IndentedJSON(http.StatusAccepted, rel)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HelmHandler) RunTests(c *gin.Context) {
|
||||
rel := h.getRelease(c)
|
||||
if rel == nil {
|
||||
return // error state is set inside
|
||||
}
|
||||
|
||||
out, err := rel.RunTests()
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *HelmHandler) GetInfoSection(c *gin.Context) {
|
||||
if c.Query("revision") != "" { // don't cache if latest is requested
|
||||
h.EnableClientCache(c)
|
||||
}
|
||||
|
||||
rel := h.getRelease(c)
|
||||
if rel == nil {
|
||||
return // error state is set inside
|
||||
}
|
||||
|
||||
revn, err := strconv.Atoi(c.Query("revision"))
|
||||
if c.Query("revision") != "" && err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
rev, err := rel.GetRev(revn)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
var revDiff *objects.Release
|
||||
revS := c.Query("revisionDiff")
|
||||
if revS != "" {
|
||||
revN, err := strconv.Atoi(revS)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
revDiff, err = rel.GetRev(revN)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
out = subproc.GetDiff(strings.TrimSpace(manifests), out, "current.yaml", "upgraded.yaml")
|
||||
} else {
|
||||
c.Header("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
c.String(http.StatusAccepted, out)
|
||||
}
|
||||
flag := c.Query("userDefined") == "true"
|
||||
|
||||
func (h *HelmHandler) GetInfoSection(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, true)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
flag := c.Query("flag") == "true"
|
||||
rDiff := c.Query("revisionDiff")
|
||||
res, err := handleGetSection(h.Data, c.Param("section"), rDiff, qp, flag)
|
||||
res, err := h.handleGetSection(rev, c.Param("section"), revDiff, flag)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -194,25 +402,53 @@ func (h *HelmHandler) GetInfoSection(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 {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, out)
|
||||
}
|
||||
|
||||
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 {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := []RepositoryElement{}
|
||||
for _, r := range repos {
|
||||
out = append(out, RepositoryElement{
|
||||
Name: r.Orig.Name,
|
||||
URL: r.Orig.URL,
|
||||
})
|
||||
}
|
||||
|
||||
c.IndentedJSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
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 {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -221,13 +457,12 @@ func (h *HelmHandler) RepoAdd(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *HelmHandler) RepoDelete(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
app := h.GetApp(c)
|
||||
if app == nil {
|
||||
return // sets error inside
|
||||
}
|
||||
|
||||
_, err = h.Data.ChartRepoDelete(qp.Name)
|
||||
err := app.Repositories.Delete(c.Param("name"))
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -235,11 +470,30 @@ func (h *HelmHandler) RepoDelete(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func handleGetSection(data *subproc.DataLayer, section string, rDiff string, qp *utils.QueryProps, flag bool) (string, error) {
|
||||
sections := map[string]subproc.SectionFn{
|
||||
"manifests": data.RevisionManifests,
|
||||
"values": data.RevisionValues,
|
||||
"notes": data.RevisionNotes,
|
||||
func (h *HelmHandler) handleGetSection(rel *objects.Release, section string, rDiff *objects.Release, flag bool) (string, error) {
|
||||
sections := map[string]objects.SectionFn{
|
||||
"manifests": func(qp *release.Release, b bool) (string, error) { return qp.Manifest, nil },
|
||||
"notes": func(qp *release.Release, b bool) (string, error) { return qp.Info.Notes, nil },
|
||||
"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]
|
||||
@@ -247,27 +501,130 @@ func handleGetSection(data *subproc.DataLayer, section string, rDiff string, qp
|
||||
return "", errors.New("unsupported section: " + section)
|
||||
}
|
||||
|
||||
if rDiff != "" {
|
||||
cRevDiff, err := strconv.Atoi(rDiff)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if rDiff != nil {
|
||||
ext := ".yaml"
|
||||
if section == "notes" {
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
res, err := functor(qp.Namespace, qp.Name, qp.Revision, flag)
|
||||
res, err := functor(rel.Orig, flag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", errorx.Decorate(err, "failed to get section info")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/joomcode/errorx"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
v12 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
|
||||
)
|
||||
|
||||
type KubeHandler struct {
|
||||
Data *subproc.DataLayer
|
||||
*Contexted
|
||||
}
|
||||
|
||||
func (h *KubeHandler) GetContexts(c *gin.Context) {
|
||||
app := h.GetApp(c)
|
||||
if app == nil {
|
||||
return // sets error inside
|
||||
}
|
||||
|
||||
res, err := h.Data.ListContexts()
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
@@ -24,21 +29,33 @@ func (h *KubeHandler) GetContexts(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *KubeHandler) GetResourceInfo(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
qp, err := utils.GetQueryProps(c)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.Data.GetResource(qp.Namespace, &v12.Carp{
|
||||
TypeMeta: v1.TypeMeta{Kind: c.Param("kind")},
|
||||
ObjectMeta: v1.ObjectMeta{Name: qp.Name},
|
||||
})
|
||||
if err != nil {
|
||||
app := h.GetApp(c)
|
||||
if app == nil {
|
||||
return // sets error inside
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
EnhanceStatus(res)
|
||||
|
||||
c.IndentedJSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func EnhanceStatus(res *v12.Carp) {
|
||||
// custom logic to provide most meaningful status for the resource
|
||||
if res.Status.Phase == "Active" || res.Status.Phase == "Error" {
|
||||
_ = res.Name + ""
|
||||
@@ -52,18 +69,21 @@ func (h *KubeHandler) GetResourceInfo(c *gin.Context) {
|
||||
} else if res.Status.Phase == "" {
|
||||
res.Status.Phase = "Exists"
|
||||
}
|
||||
|
||||
c.IndentedJSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (h *KubeHandler) Describe(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
qp, err := utils.GetQueryProps(c)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
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 {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -73,11 +93,21 @@ func (h *KubeHandler) Describe(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 {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, res)
|
||||
c.IndentedJSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
type ScannersHandler struct {
|
||||
Data *subproc.DataLayer
|
||||
*Contexted
|
||||
}
|
||||
|
||||
func (h *ScannersHandler) List(c *gin.Context) {
|
||||
@@ -26,23 +26,10 @@ func (h *ScannersHandler) List(c *gin.Context) {
|
||||
c.IndentedJSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (h *ScannersHandler) ScanDraftManifest(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
|
||||
}
|
||||
|
||||
func (h *ScannersHandler) ScanManifest(c *gin.Context) {
|
||||
reps := map[string]*subproc.ScanResults{}
|
||||
for _, scanner := range h.Data.Scanners {
|
||||
sr, err := scanner.ScanManifests(mnf)
|
||||
sr, err := scanner.ScanManifests(c.PostForm("manifest"))
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -55,7 +42,7 @@ func (h *ScannersHandler) ScanDraftManifest(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *ScannersHandler) ScanResource(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
qp, err := utils.GetQueryProps(c)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
|
||||
49
pkg/dashboard/objects/app.go
Normal file
49
pkg/dashboard/objects/app.go
Normal 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package subproc
|
||||
package objects
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -12,15 +12,6 @@ import (
|
||||
|
||||
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 {
|
||||
Marshaler *marshaler.Marshaler `json:"-"`
|
||||
HitCount int
|
||||
@@ -83,18 +74,3 @@ func (c *Cache) Clear() error {
|
||||
c.MissCount = 0
|
||||
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
|
||||
}
|
||||
232
pkg/dashboard/objects/data.go
Normal file
232
pkg/dashboard/objects/data.go
Normal 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.")
|
||||
}
|
||||
58
pkg/dashboard/objects/data_test.go
Normal file
58
pkg/dashboard/objects/data_test.go
Normal 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")
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
198
pkg/dashboard/objects/kubectl.go
Normal file
198
pkg/dashboard/objects/kubectl.go
Normal 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
|
||||
}
|
||||
399
pkg/dashboard/objects/releases.go
Normal file
399
pkg/dashboard/objects/releases.go
Normal 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)
|
||||
}
|
||||
80
pkg/dashboard/objects/releases_test.go
Normal file
80
pkg/dashboard/objects/releases_test.go
Normal 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")
|
||||
}
|
||||
312
pkg/dashboard/objects/repos.go
Normal file
312
pkg/dashboard/objects/repos.go
Normal 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)
|
||||
}
|
||||
149
pkg/dashboard/objects/repos_test.go
Normal file
149
pkg/dashboard/objects/repos_test.go
Normal 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)
|
||||
}
|
||||
30
pkg/dashboard/objects/testdata/repositories.yaml
vendored
Normal file
30
pkg/dashboard/objects/testdata/repositories.yaml
vendored
Normal 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: ""
|
||||
@@ -2,6 +2,8 @@ package scanners
|
||||
|
||||
import (
|
||||
"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/utils"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
@@ -11,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
type Checkov struct {
|
||||
Data *subproc.DataLayer
|
||||
Data *objects.DataLayer
|
||||
}
|
||||
|
||||
func (c *Checkov) ManifestIsScannable() bool {
|
||||
@@ -77,7 +79,7 @@ func (c *Checkov) ScanManifests(mnf string) (*subproc.ScanResults, error) {
|
||||
|
||||
res := &subproc.ScanResults{}
|
||||
|
||||
err = json.Unmarshal([]byte(out), res.OrigReport)
|
||||
err = json.Unmarshal([]byte(out), &res.OrigReport)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,14 +91,19 @@ func (c *Checkov) ScanResource(ns string, kind string, name string) (*subproc.Sc
|
||||
carp := v1.Carp{}
|
||||
carp.Kind = kind
|
||||
carp.Name = name
|
||||
mnf, err := c.Data.GetResourceYAML(ns, &carp)
|
||||
app, err := c.Data.AppForCtx(c.Data.KubeContext)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errorx.Decorate(err, "failed to create temporary file")
|
||||
}
|
||||
defer fclose()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package scanners
|
||||
|
||||
import (
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/objects"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
type Trivy struct {
|
||||
Data *subproc.DataLayer
|
||||
Data *objects.DataLayer
|
||||
}
|
||||
|
||||
func (c *Trivy) ManifestIsScannable() bool {
|
||||
|
||||
@@ -4,6 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -12,54 +18,87 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hashicorp/go-version"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/scanners"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/subproc"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Version string
|
||||
Namespace string
|
||||
Namespaces []string
|
||||
Address string
|
||||
Debug bool
|
||||
NoTracking bool
|
||||
}
|
||||
|
||||
func (s Server) StartServer() (string, utils.ControlChan) {
|
||||
data := subproc.DataLayer{
|
||||
Namespace: s.Namespace,
|
||||
Cache: subproc.NewCache(),
|
||||
StatusInfo: &subproc.StatusInfo{
|
||||
CurVer: s.Version,
|
||||
Analytics: false,
|
||||
LimitedToNamespace: s.Namespace,
|
||||
},
|
||||
}
|
||||
err := data.CheckConnectivity()
|
||||
func (s *Server) StartServer(ctx context.Context, cancel context.CancelFunc) (string, utils.ControlChan, error) {
|
||||
data, err := objects.NewDataLayer(s.Namespaces, s.Version, NewHelmConfig)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to check that Helm is operational, cannot continue. The error was: %s", err)
|
||||
os.Exit(1) // TODO: propagate error instead?
|
||||
return "", nil, errorx.Decorate(err, "Failed to create data layer")
|
||||
}
|
||||
|
||||
isDevModeWithAnalytics := os.Getenv("HD_DEV_ANALYTICS") == "true"
|
||||
data.StatusInfo.Analytics = (!s.NoTracking && s.Version != "0.0.0") || isDevModeWithAnalytics
|
||||
go checkUpgrade(data.StatusInfo)
|
||||
|
||||
discoverScanners(&data)
|
||||
|
||||
abort := make(utils.ControlChan)
|
||||
api := NewRouter(abort, &data, s.Debug)
|
||||
done := s.startBackgroundServer(api, abort)
|
||||
|
||||
return "http://" + s.Address, done
|
||||
err = s.detectClusterMode(data)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
func (s Server) startBackgroundServer(routes *gin.Engine, abort utils.ControlChan) utils.ControlChan {
|
||||
go checkUpgrade(data.StatusInfo)
|
||||
|
||||
discoverScanners(data)
|
||||
|
||||
go data.PeriodicTasks(ctx)
|
||||
|
||||
api := NewRouter(cancel, data, s.Debug)
|
||||
done := s.startBackgroundServer(api, ctx)
|
||||
|
||||
return "http://" + s.Address, done, nil
|
||||
}
|
||||
|
||||
func (s *Server) detectClusterMode(data *objects.DataLayer) error {
|
||||
data.StatusInfo.ClusterMode = os.Getenv("HD_CLUSTER_MODE") != ""
|
||||
if data.StatusInfo.ClusterMode {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctxs, err := data.ListContexts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(ctxs) == 0 {
|
||||
log.Infof("Got no kubectl config contexts, will attempt to detect if we're inside cluster...")
|
||||
app, err := data.AppForCtx("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns, err := app.K8s.GetNameSpaces()
|
||||
if err != nil { // no point in continuing without kubectl context and k8s connection
|
||||
return err
|
||||
}
|
||||
log.Debugf("Got %d namespaces listed", len(ns.Items))
|
||||
data.StatusInfo.ClusterMode = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) startBackgroundServer(routes *gin.Engine, ctx context.Context) utils.ControlChan {
|
||||
done := make(utils.ControlChan)
|
||||
server := &http.Server{
|
||||
Addr: s.Address,
|
||||
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() {
|
||||
err := server.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
@@ -73,18 +112,10 @@ func (s Server) startBackgroundServer(routes *gin.Engine, abort utils.ControlCha
|
||||
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
|
||||
}
|
||||
|
||||
func (s Server) itIsUs() bool {
|
||||
func (s *Server) itIsUs() bool {
|
||||
url := fmt.Sprintf("http://%s/status", s.Address)
|
||||
var myClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
@@ -99,7 +130,7 @@ func (s Server) itIsUs() bool {
|
||||
return strings.HasPrefix(r.Header.Get("X-Application-Name"), "Helm Dashboard")
|
||||
}
|
||||
|
||||
func discoverScanners(data *subproc.DataLayer) {
|
||||
func discoverScanners(data *objects.DataLayer) {
|
||||
potential := []subproc.Scanner{
|
||||
&scanners.Checkov{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"
|
||||
type GHRelease struct {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to parse LatestVer: %s", err)
|
||||
log.Warnf("Failed to parse RepoLatestVer: %s", err)
|
||||
} else {
|
||||
if v1.LessThan(v2) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ $("#btnUpgradeCheck").click(function () {
|
||||
const repoName = self.data("repo")
|
||||
$("#btnUpgrade span").text("Checking...")
|
||||
$("#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)
|
||||
}).done(function () {
|
||||
self.find(".spinner-border").hide()
|
||||
@@ -16,31 +16,29 @@ $("#btnUpgradeCheck").click(function () {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
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)
|
||||
}).done(function (data) {
|
||||
let elm = {name: "", version: "0"}
|
||||
const btnUpgradeCheck = $("#btnUpgradeCheck");
|
||||
if (!data || !data.length) {
|
||||
$("#btnUpgrade span").text("No upgrades")
|
||||
$("#btnUpgrade .icon").removeClass("bi-hourglass-split").addClass("bi-x-octagon")
|
||||
$("#btnUpgrade").prop("disabled", true)
|
||||
$("#btnUpgradeCheck").prop("disabled", true)
|
||||
btnUpgradeCheck.prop("disabled", true)
|
||||
btnUpgradeCheck.text("")
|
||||
$("#btnAddRepository").text("Add repository for it")
|
||||
$("#btnUpgradeCheck").text("")
|
||||
return
|
||||
} else {
|
||||
$("#btnAddRepository").text("")
|
||||
btnUpgradeCheck.text("Check for new version")
|
||||
elm = data[0]
|
||||
}
|
||||
|
||||
$("#btnUpgrade .icon").removeClass("bi-x-octagon").addClass("bi-hourglass-split")
|
||||
$("#btnAddRepository").text("")
|
||||
$("#btnUpgradeCheck").text("Check for new version")
|
||||
$("#btnUpgrade .icon").removeClass("bi-arrow-up bi-pencil").addClass("bi-hourglass-split")
|
||||
const verCur = $("#specRev").data("last-chart-ver");
|
||||
const elm = data[0]
|
||||
$("#btnUpgradeCheck").data("repo", elm.name.split('/').shift())
|
||||
$("#btnUpgradeCheck").data("chart", elm.name.split('/').pop())
|
||||
btnUpgradeCheck.data("repo", elm.repository)
|
||||
btnUpgradeCheck.data("chart", elm.name)
|
||||
|
||||
const canUpgrade = isNewerVersion(verCur, elm.version);
|
||||
$("#btnUpgradeCheck").prop("disabled", false)
|
||||
btnUpgradeCheck.prop("disabled", false)
|
||||
if (canUpgrade) {
|
||||
$("#btnUpgrade span").text("Upgrade to " + elm.version)
|
||||
$("#btnUpgrade .icon").removeClass("bi-hourglass-split").addClass("bi-arrow-up")
|
||||
@@ -58,7 +56,14 @@ function checkUpgradeable(name) {
|
||||
function popUpUpgrade(elm, ns, name, verCur, lastRev) {
|
||||
$("#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)
|
||||
|
||||
@@ -69,14 +74,23 @@ function popUpUpgrade(elm, ns, name, verCur, lastRev) {
|
||||
$("#upgradeModal .ver-old").show().find("span").text(verCur)
|
||||
$("#upgradeModal .rel-name").prop("disabled", true).val(name)
|
||||
$("#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 {
|
||||
$("#upgradeModalLabel .type").text("Install")
|
||||
$("#upgradeModal .ver-old").hide()
|
||||
$("#upgradeModal .rel-name").prop("disabled", false).val(elm.name.split("/").pop())
|
||||
$("#upgradeModal .rel-ns").prop("disabled", false).val(ns)
|
||||
$('#upgradeModal').data("curManifest", "")
|
||||
}
|
||||
|
||||
$.getJSON("/api/helm/repo/search?name=" + elm.name).fail(function (xhr) {
|
||||
if (elm.name) {
|
||||
$.getJSON("/api/helm/repositories/versions?name=" + elm.name).fail(function (xhr) {
|
||||
reportError("Failed to find chart in repo", xhr)
|
||||
}).done(function (vers) {
|
||||
// fill versions
|
||||
@@ -91,14 +105,22 @@ function popUpUpgrade(elm, ns, name, verCur, lastRev) {
|
||||
$('#upgradeModal select').append(opt)
|
||||
}
|
||||
|
||||
$('#upgradeModal select').val(elm.version).trigger("change")
|
||||
$('#upgradeModal select').val(elm.version).trigger("change").parent().show()
|
||||
upgrPopUpCommon(verCur, ns, lastRev, name)
|
||||
})
|
||||
} else { // chart without repo reconfigure
|
||||
$('#upgradeModal select').empty().trigger("change").parent().hide()
|
||||
upgrPopUpCommon(verCur, ns, lastRev, name)
|
||||
}
|
||||
}
|
||||
|
||||
function upgrPopUpCommon(verCur, ns, lastRev, name) {
|
||||
const myModal = new bootstrap.Modal(document.getElementById('upgradeModal'), {});
|
||||
myModal.show()
|
||||
|
||||
if (verCur) {
|
||||
// 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)
|
||||
}).done(function (data) {
|
||||
$("#upgradeModal textarea").val(data).data("dirty", false)
|
||||
@@ -106,16 +128,16 @@ function popUpUpgrade(elm, ns, name, verCur, lastRev) {
|
||||
} else {
|
||||
$("#upgradeModal textarea").val("").data("dirty", true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$("#upgradeModal .btn-confirm").click(function () {
|
||||
const btnConfirm = $("#upgradeModal .btn-confirm")
|
||||
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({
|
||||
type: 'POST',
|
||||
url: "/api/helm/charts/install" + upgradeModalQstr() + "&flag=true",
|
||||
data: $("#upgradeModal textarea").data("dirty") ? $("#upgradeModal form").serialize() : null,
|
||||
url: upgradeModalURL(),
|
||||
data: $("#upgradeModal form").serialize(),
|
||||
}).fail(function (xhr) {
|
||||
reportError("Failed to upgrade the chart", xhr)
|
||||
}).done(function (data) {
|
||||
@@ -156,22 +178,33 @@ $('#upgradeModal select').change(function () {
|
||||
|
||||
// fill reference values
|
||||
$("#upgradeModal .ref-vals").html('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
|
||||
$.get("/api/helm/repo/values?chart=" + $("#upgradeModal").data("chart") + "&version=" + self.val()).fail(function (xhr) {
|
||||
const chart = $("#upgradeModal").data("chart");
|
||||
// TODO: if chart is empty, query different URL that will restore values without repo
|
||||
if (chart) {
|
||||
$.get("/api/helm/repositories/values?chart=" + chart + "&version=" + self.val()).fail(function (xhr) {
|
||||
reportError("Failed to get upgrade info", xhr)
|
||||
}).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 () {
|
||||
const self = $(this)
|
||||
|
||||
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({
|
||||
type: "POST",
|
||||
url: "/api/scanners/manifests" + upgradeModalQstr(),
|
||||
data: $("#upgradeModal form").serialize(),
|
||||
url: "/api/scanners/manifests",
|
||||
processData: false,
|
||||
contentType: false,
|
||||
data: form,
|
||||
}).fail(function (xhr) {
|
||||
reportError("Failed to scan the manifest", xhr)
|
||||
}).done(function (data) {
|
||||
@@ -185,7 +218,7 @@ $('#upgradeModal .btn-scan').click(function () {
|
||||
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(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...')
|
||||
$("#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")) {
|
||||
$("#upgradeModal .invalid-feedback").hide()
|
||||
values = $("#upgradeModal form").serialize()
|
||||
|
||||
try {
|
||||
jsyaml.load($("#upgradeModal textarea").val())
|
||||
@@ -219,8 +252,23 @@ function requestChangeDiff() {
|
||||
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/api/helm/charts/install" + upgradeModalQstr(),
|
||||
data: values,
|
||||
url: upgradeModalURL(),
|
||||
data: form,
|
||||
}).fail(function (xhr) {
|
||||
$("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: " + xhr.responseText + "</p>")
|
||||
}).done(function (data) {
|
||||
$('#upgradeModal').data("newManifest", data.manifest)
|
||||
|
||||
const form = new FormData();
|
||||
form.append('a', $('#upgradeModal').data("curManifest"));
|
||||
form.append('b', data.manifest);
|
||||
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/diff",
|
||||
processData: false,
|
||||
contentType: false,
|
||||
data: form,
|
||||
}).fail(function (xhr) {
|
||||
$("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: " + xhr.responseText + "</p>")
|
||||
}).done(function (data) {
|
||||
@@ -238,17 +286,18 @@ function requestChangeDiff() {
|
||||
diffBody.html("No changes will happen to the cluster")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function upgradeModalQstr() {
|
||||
let qstr = "?" +
|
||||
"namespace=" + $("#upgradeModal .rel-ns").val() +
|
||||
"&name=" + $("#upgradeModal .rel-name").val() +
|
||||
"&chart=" + $("#upgradeModal").data("chart") +
|
||||
"&version=" + $('#upgradeModal select').val()
|
||||
function upgradeModalURL() {
|
||||
let ns = $("#upgradeModal .rel-ns").val();
|
||||
if (!ns) {
|
||||
ns = "[empty]"
|
||||
}
|
||||
|
||||
if ($("#upgradeModal").data("initial")) {
|
||||
qstr += "&initial=true"
|
||||
let qstr = "/api/helm/releases/" + ns;
|
||||
if (!$("#upgradeModal").data("initial")) {
|
||||
qstr += "/" + $("#upgradeModal .rel-name").val()
|
||||
}
|
||||
|
||||
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>')
|
||||
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>')
|
||||
const url = "/api/helm/charts?namespace=" + namespace + "&name=" + chart;
|
||||
const url = "/api/helm/releases/" + namespace + "/" + chart;
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'DELETE',
|
||||
@@ -277,9 +326,7 @@ $("#btnUninstall").click(function () {
|
||||
const myModal = new bootstrap.Modal(document.getElementById('confirmModal'));
|
||||
myModal.show()
|
||||
|
||||
let qstr = "name=" + chart + "&namespace=" + namespace + "&revision=" + revision
|
||||
let url = "/api/helm/charts/resources"
|
||||
url += "?" + qstr
|
||||
let url = "/api/helm/releases/" + namespace + "/" + chart + "/resources"
|
||||
$.getJSON(url).fail(function (xhr) {
|
||||
reportError("Failed to get list of resources", xhr)
|
||||
}).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>')
|
||||
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>')
|
||||
const url = "/api/helm/charts/rollback?namespace=" + namespace + "&name=" + chart + "&revision=" + revisionNew;
|
||||
const url = "/api/helm/releases/" + namespace + "/" + chart + "/rollback";
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'POST',
|
||||
data: {
|
||||
revision: revisionNew
|
||||
}
|
||||
}).fail(function (xhr) {
|
||||
reportError("Failed to rollback the chart", xhr)
|
||||
}).done(function () {
|
||||
@@ -315,8 +365,8 @@ $("#btnRollback").click(function () {
|
||||
const myModal = new bootstrap.Modal(document.getElementById('confirmModal'), {});
|
||||
myModal.show()
|
||||
|
||||
let qstr = "name=" + chart + "&namespace=" + namespace + "&revision=" + revisionNew + "&revisionDiff=" + revisionCur
|
||||
let url = "/api/helm/charts/manifests"
|
||||
let qstr = "revision=" + revisionNew + "&revisionDiff=" + revisionCur
|
||||
let url = "/api/helm/releases/" + namespace + "/" + chart + "/manifests"
|
||||
url += "?" + qstr
|
||||
$.get(url).fail(function (xhr) {
|
||||
reportError("Failed to get list of resources", xhr)
|
||||
@@ -343,3 +393,25 @@ $("#btnAddRepository").click(function () {
|
||||
setHashParam("section", "repository")
|
||||
window.location.reload()
|
||||
})
|
||||
|
||||
$("#btnTest").click(function() {
|
||||
const myModal = new bootstrap.Modal(document.getElementById('testModal'), {});
|
||||
$("#testModal .test-result").empty().prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Waiting for completion...')
|
||||
myModal.show()
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: "/api/helm/releases/" + getHashParam("namespace") + "/" + getHashParam("chart") + "/test"
|
||||
}).fail(function (xhr) {
|
||||
reportError("Failed to execute test for chart", xhr)
|
||||
myModal.hide()
|
||||
}).done(function (data) {
|
||||
var output;
|
||||
if(data.length == 0 || data == null || data == "") {
|
||||
output = "<div>Tests executed successfully<br><br><pre>Empty response from API<pre></div>"
|
||||
} else {
|
||||
output = data.replaceAll("\n", "<br>")
|
||||
}
|
||||
$("#testModal .test-result").empty().html(output)
|
||||
myModal.show()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ xhr.onload = function () {
|
||||
const version = status.CurVer
|
||||
if (status.Analytics) {
|
||||
enableDD(version)
|
||||
enableHeap(version)
|
||||
enableHeap(version, status.ClusterMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ function enableDD(version) {
|
||||
})
|
||||
}
|
||||
|
||||
function enableHeap(version) {
|
||||
function enableHeap(version, inCluster) {
|
||||
window.heap = window.heap || [], heap.load = function (e, t) {
|
||||
window.heap.appid = e, window.heap.config = t = t || {};
|
||||
let r = document.createElement("script");
|
||||
@@ -56,7 +56,10 @@ function enableHeap(version) {
|
||||
}, p = ["addEventProperties", "addUserProperties", "clearEventProperties", "identify", "resetIdentity", "removeEventProperty", "setEventProperties", "track", "unsetEventProperty"], o = 0; o < p.length; o++) heap[p[o]] = n(p[o])
|
||||
};
|
||||
heap.load("4249623943");
|
||||
window.heap.addEventProperties({'version': version});
|
||||
window.heap.addEventProperties({
|
||||
'version': version,
|
||||
'installationMode': inCluster?"cluster":"local"
|
||||
});
|
||||
}
|
||||
|
||||
function sendStats(name, prop){
|
||||
|
||||
70
pkg/dashboard/static/api-docs.html
Normal file
70
pkg/dashboard/static/api-docs.html
Normal 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>
|
||||
@@ -55,17 +55,17 @@ function loadContentWrapper() {
|
||||
loadContent(getHashParam("tab"), getHashParam("namespace"), getHashParam("chart"), revision, revDiff, flag)
|
||||
}
|
||||
|
||||
function loadContent(mode, namespace, name, revision, revDiff, flag) {
|
||||
let qstr = "name=" + name + "&namespace=" + namespace + "&revision=" + revision
|
||||
function loadContent(mode, namespace, name, revision, revDiff, userDefined) {
|
||||
let qstr = "revision=" + revision
|
||||
if (revDiff) {
|
||||
qstr += "&revisionDiff=" + revDiff
|
||||
}
|
||||
|
||||
if (flag) {
|
||||
qstr += "&flag=" + flag
|
||||
if (userDefined) {
|
||||
qstr += "&userDefined=" + userDefined
|
||||
}
|
||||
|
||||
let url = "/api/helm/charts/" + mode
|
||||
let url = "/api/helm/releases/" + namespace + "/" + name + "/" + mode
|
||||
url += "?" + qstr
|
||||
const diffDisplay = $("#manifestText");
|
||||
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 interestingResources = ["STATEFULSET", "DEAMONSET", "DEPLOYMENT"];
|
||||
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/charts/resources"
|
||||
url += "?" + qstr
|
||||
let url = "/api/helm/releases/" + namespace + "/" + chart + "/resources"
|
||||
$.getJSON(url).fail(function (xhr) {
|
||||
reportError("Failed to get list of resources", xhr)
|
||||
}).done(function (data) {
|
||||
@@ -182,7 +180,7 @@ function showResources(namespace, chart, revision) {
|
||||
|
||||
resBody.append(resBlock)
|
||||
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")
|
||||
}).done(function (data) {
|
||||
const badge = $("<span class='badge me-2 fw-normal'></span>").text(data.status.phase);
|
||||
@@ -239,7 +237,7 @@ function showDescribe(ns, kind, name, badge) {
|
||||
|
||||
const myModal = new bootstrap.Offcanvas(document.getElementById('describeModal'));
|
||||
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)
|
||||
}).done(function (data) {
|
||||
data = hljs.highlight(data, {language: 'yaml'}).value
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</a>
|
||||
<ul class="dropdown-menu fs-80">
|
||||
<li><a class="dropdown-item"
|
||||
href="https://join.slack.com/t/komodorkommunity/shared_invite/zt-1dm3cnkue-ov1Yh~_95teA35QNx5yuMg"
|
||||
href="https://komodorkommunity.slack.com"
|
||||
target="_blank"><i class="bi-slack"></i> Support Chat</a></li>
|
||||
<li><a class="dropdown-item" href="https://github.com/komodorio/helm-dashboard" target="_blank"><i
|
||||
class="bi-github"></i> Project Page</a></li>
|
||||
@@ -54,7 +54,8 @@
|
||||
<button class="dropdown-item" id="cacheClear"><i
|
||||
class="bi-arrow-repeat"></i> Reset Cache
|
||||
</button>
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="api-docs" target="_blank"><i
|
||||
class="bi-braces"></i> REST API</a></li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
@@ -194,6 +195,10 @@
|
||||
<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>
|
||||
</button>
|
||||
<button id="btnTest"
|
||||
class="btn btn-sm btn-light bg-white border border-secondary me-2 display-none"
|
||||
title="Run tests for this chart"><i class="bi-check-circle"></i> <span>Run tests</span>
|
||||
</button>
|
||||
<button id="btnUninstall" class="btn btn-sm btn-light bg-white border border-secondary"
|
||||
title="Uninstall the chart"><i class="bi-trash3"></i> Uninstall
|
||||
</button>
|
||||
@@ -364,14 +369,16 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="input-group mb-3 text-muted">
|
||||
<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 class="form-label me-4 text-dark">
|
||||
Namespace (optional):
|
||||
@@ -417,6 +424,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="testModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog modal-dialog-scrollable modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="testModalLabel">
|
||||
<span class="type">Test results</span>
|
||||
</h4>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body border-bottom fs-5">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<span class="test-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="PowerOffModal" tabindex="-1" aria-labelledby="ModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
@@ -452,5 +479,47 @@
|
||||
<script src="static/actions.js"></script>
|
||||
<script src="static/scripts.js"></script>
|
||||
|
||||
<!-- BANNER START -->
|
||||
<a id="banner"
|
||||
href="https://helm-dashboard-survey.komodor.com/"
|
||||
class="display-none position-absolute top-0 start-50 translate-middle-x bg-primary text-light rounded px-2 mt-1 text-decoration-none py-1">Help
|
||||
shaping the future by participating in user survey <b class="bi-x-lg"></b></a>
|
||||
<script>
|
||||
function setCookie(name, value, days) {
|
||||
let expires = "";
|
||||
if (days) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
expires = "; expires=" + date.toUTCString();
|
||||
}
|
||||
document.cookie = name + "=" + (value || "") + expires + "; path=/";
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(';');
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
const c = ca[i].trim();
|
||||
if (c.indexOf(nameEQ) === 0) {
|
||||
return c.substring(nameEQ.length, c.length)
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookie = getCookie("hideBanner");
|
||||
if (cookie == null) {
|
||||
console.log("show")
|
||||
$("#banner").show()
|
||||
}
|
||||
|
||||
$("#banner b").click(function (evt) {
|
||||
evt.preventDefault()
|
||||
setCookie("hideBanner", "1", 365);
|
||||
$("#banner").hide()
|
||||
})
|
||||
</script>
|
||||
<!-- /BANNER END -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@ function loadChartsList() {
|
||||
$("#sectionList").show()
|
||||
const chartsCards = $("#installedList .body")
|
||||
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'});
|
||||
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>")
|
||||
@@ -44,37 +44,13 @@ function buildChartCard(elm) {
|
||||
<div class="col-1 rel-date text-nowrap"><span>today</span><div>Updated</div></div>
|
||||
</div>`)
|
||||
|
||||
let chartName = elm.chart
|
||||
// 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-]+)*))?'
|
||||
const match = elm.chart.match(chartNameRegex);
|
||||
if (match) {
|
||||
chartName = elm.chart.substring(0, match.index - 1)
|
||||
} else {
|
||||
// fall back to simple substr
|
||||
chartName = elm.chart.substring(0, elm.chart.lastIndexOf("-"))
|
||||
}
|
||||
$.getJSON("/api/helm/repo/search?name=" + chartName).fail(function (xhr) {
|
||||
// we're ok if we can't show icon and description
|
||||
console.log("Failed to get repo name for charts", xhr)
|
||||
}).done(function (data) {
|
||||
if (data.length > 0) {
|
||||
$.getJSON("/api/helm/charts/show?name=" + data[0].name).fail(function (xhr) {
|
||||
console.log("Failed to get chart", xhr)
|
||||
}).done(function (data) {
|
||||
if (data) {
|
||||
const res = data[0];
|
||||
if (res.icon) {
|
||||
card.find(".rel-name").attr("style", "background-image: url(" + res.icon + ")")
|
||||
}
|
||||
if (res.description) {
|
||||
card.find(".rel-name div").text(res.description)
|
||||
}
|
||||
if (elm.icon) {
|
||||
card.find(".rel-name").attr("style", "background-image: url(" + elm.icon + ")")
|
||||
}
|
||||
|
||||
})
|
||||
if (elm.description) {
|
||||
card.find(".rel-name div").text(elm.description)
|
||||
}
|
||||
})
|
||||
|
||||
card.find(".rel-name span").text(elm.name)
|
||||
card.find(".rel-rev span").text("#" + elm.revision)
|
||||
|
||||
749
pkg/dashboard/static/openapi.json
Normal file
749
pkg/dashboard/static/openapi.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ function loadRepoView() {
|
||||
$("#sectionRepo .repo-details").hide()
|
||||
$("#sectionRepo").show()
|
||||
|
||||
$.getJSON("/api/helm/repo").fail(function (xhr) {
|
||||
$.getJSON("/api/helm/repositories").fail(function (xhr) {
|
||||
reportError("Failed to get list of repositories", xhr)
|
||||
sendStats('Get repo', {'status': 'fail'});
|
||||
}).done(function (data) {
|
||||
@@ -31,7 +31,7 @@ function loadRepoView() {
|
||||
$("#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>')
|
||||
$.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)
|
||||
}).done(function (data) {
|
||||
$("#sectionRepo .repo-details ul").empty()
|
||||
@@ -42,6 +42,11 @@ function loadRepoView() {
|
||||
<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>
|
||||
</li>`)
|
||||
|
||||
if (elm.icon) {
|
||||
li.find("h6").prepend('<img src="' + elm.icon + '" class="me-1" style="height: 1rem"/>')
|
||||
}
|
||||
|
||||
li.data("item", elm)
|
||||
|
||||
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>')
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: "/api/helm/repo",
|
||||
url: "/api/helm/repositories",
|
||||
data: $("#repoAddModal form").serialize(),
|
||||
}).fail(function (xhr) {
|
||||
reportError("Failed to add repo", xhr)
|
||||
@@ -100,7 +105,7 @@ $("#sectionRepo .btn-remove").click(function () {
|
||||
if (confirm("Confirm removing repository?")) {
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: "/api/helm/repo?name=" + $("#sectionRepo .repo-details h2").text(),
|
||||
url: "/api/helm/repositories/" + $("#sectionRepo .repo-details h2").text(),
|
||||
}).fail(function (xhr) {
|
||||
reportError("Failed to add repo", xhr)
|
||||
}).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>')
|
||||
$.ajax({
|
||||
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) {
|
||||
reportError("Failed to add repo", xhr)
|
||||
}).done(function () {
|
||||
@@ -132,8 +137,11 @@ function repoChartClicked() {
|
||||
window.location.reload()
|
||||
} else {
|
||||
const contexts = $("body").data("contexts")
|
||||
const ctxFiltered = contexts.filter(obj => {return obj.Name === getHashParam("context")});
|
||||
const ctxFiltered = contexts.filter(obj => {
|
||||
return obj.Name === getHashParam("context")
|
||||
});
|
||||
const contextNamespace = ctxFiltered.length ? ctxFiltered[0].Namespace : ""
|
||||
elm.repository = $("#sectionRepo .repo-details h2").text()
|
||||
popUpUpgrade(elm, contextNamespace)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ function loadChartHistory(namespace, name) {
|
||||
$("#sectionDetails").show()
|
||||
$("#sectionDetails .name").text(name)
|
||||
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)
|
||||
}).done(function (data) {
|
||||
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")
|
||||
if (rev) {
|
||||
|
||||
@@ -45,7 +45,7 @@ function fillClusters(limNS) {
|
||||
filterInstalledList($("#installedList .body .row"))
|
||||
})
|
||||
|
||||
$.getJSON("/api/kube/contexts").fail(function (xhr) {
|
||||
$.getJSON("/api/k8s/contexts").fail(function (xhr) {
|
||||
sendStats('contexts', {'status': 'fail'});
|
||||
reportError("Failed to get list of clusters", xhr)
|
||||
}).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)))
|
||||
fillClusterList(data, context);
|
||||
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)
|
||||
}).done(function (res) {
|
||||
const ns = res.items.map(i => i.metadata.name)
|
||||
|
||||
@@ -89,10 +89,11 @@ body > .container-fluid {
|
||||
|
||||
#filters {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.8rem;
|
||||
line-height: 175%;
|
||||
inline-size: auto;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
#cluster input, #cluster span {
|
||||
@@ -296,3 +297,7 @@ nav .nav-tabs .nav-link.active {
|
||||
#sectionRepo .repo-details ul .row:hover .btn {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
package subproc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hexops/gotextdiff"
|
||||
"github.com/hexops/gotextdiff/myers"
|
||||
"github.com/hexops/gotextdiff/span"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
"helm.sh/helm/v3/pkg/release"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
|
||||
)
|
||||
|
||||
type DataLayer struct {
|
||||
KubeContext string
|
||||
Helm string
|
||||
Kubectl string
|
||||
Scanners []Scanner
|
||||
StatusInfo *StatusInfo
|
||||
Namespace string
|
||||
Cache *Cache
|
||||
}
|
||||
|
||||
type StatusInfo struct {
|
||||
CurVer string
|
||||
LatestVer string
|
||||
Analytics bool
|
||||
LimitedToNamespace string
|
||||
CacheHitRatio float64
|
||||
ClusterMode bool
|
||||
}
|
||||
|
||||
func (d *DataLayer) runCommand(cmd ...string) (string, error) {
|
||||
for i, c := range cmd {
|
||||
// TODO: remove namespace parameter if it's empty
|
||||
if c == "--namespace" && i < len(cmd) { // TODO: in case it's not found - add it?
|
||||
d.forceNamespace(&cmd[i+1])
|
||||
}
|
||||
}
|
||||
|
||||
return utils.RunCommand(cmd, map[string]string{"HELM_KUBECONTEXT": d.KubeContext})
|
||||
}
|
||||
|
||||
func (d *DataLayer) runCommandHelm(cmd ...string) (string, error) {
|
||||
if d.Helm == "" {
|
||||
d.Helm = "helm"
|
||||
}
|
||||
|
||||
cmd = append([]string{d.Helm}, cmd...)
|
||||
if d.KubeContext != "" {
|
||||
cmd = append(cmd, "--kube-context", d.KubeContext)
|
||||
}
|
||||
|
||||
return d.runCommand(cmd...)
|
||||
}
|
||||
|
||||
func (d *DataLayer) forceNamespace(s *string) {
|
||||
if d.Namespace != "" {
|
||||
*s = d.Namespace
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataLayer) CheckConnectivity() error {
|
||||
contexts, err := d.ListContexts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(contexts) < 1 {
|
||||
log.Debugf("Did not find any contexts, will try checking k8s")
|
||||
_, err := d.runCommandKubectl("get", "pods")
|
||||
if err != nil {
|
||||
log.Debugf("The error were: %s", err)
|
||||
return errors.New("did not find any kubectl contexts configured")
|
||||
}
|
||||
log.Infof("Assuming k8s environment")
|
||||
d.StatusInfo.ClusterMode = true
|
||||
}
|
||||
|
||||
_, err = d.runCommandHelm("--help")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DataLayer) GetStatus() *StatusInfo {
|
||||
sum := float64(d.Cache.HitCount + d.Cache.MissCount)
|
||||
if sum > 0 {
|
||||
d.StatusInfo.CacheHitRatio = float64(d.Cache.HitCount) / sum
|
||||
} else {
|
||||
d.StatusInfo.CacheHitRatio = 0
|
||||
}
|
||||
return d.StatusInfo
|
||||
}
|
||||
|
||||
func (d *DataLayer) ListInstalled() (res []ReleaseElement, err error) {
|
||||
cmd := []string{"ls", "--all", "--output", "json", "--time-format", time.RFC3339}
|
||||
|
||||
// TODO: filter by namespace
|
||||
if d.Namespace == "" {
|
||||
cmd = append(cmd, "--all-namespaces")
|
||||
} else {
|
||||
cmd = append(cmd, "--namespace", d.Namespace)
|
||||
}
|
||||
|
||||
out, err := d.Cache.String(CacheKeyRelList, nil, func() (string, error) {
|
||||
return d.runCommandHelm(cmd...)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(out), &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (d *DataLayer) ReleaseHistory(namespace string, releaseName string) (res []*HistoryElement, err error) {
|
||||
// TODO: there is `max` but there is no `offset`
|
||||
ct := cacheTagRelease(namespace, releaseName)
|
||||
out, err := d.Cache.String(CacheKeyRelHistory+ct, []string{ct}, func() (string, error) {
|
||||
return d.runCommandHelm("history", releaseName, "--namespace", namespace, "--output", "json")
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(out), &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, elm := range res {
|
||||
chartRepoName, curVer, err := utils.ChartAndVersion(elm.Chart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
elm.ChartName = chartRepoName // TODO: move it to frontend?
|
||||
elm.ChartVer = curVer
|
||||
elm.Updated.Time = elm.Updated.Time.Round(time.Second)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type SectionFn = func(string, string, int, bool) (string, error) // TODO: rework it into struct-based argument?
|
||||
|
||||
func (d *DataLayer) RevisionManifests(namespace string, chartName string, revision int, _ bool) (res string, err error) {
|
||||
cmd := []string{"get", "manifest", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision)}
|
||||
|
||||
key := CacheKeyRevManifests + "\v" + namespace + "\v" + chartName + "\v" + strconv.Itoa(revision)
|
||||
return d.Cache.String(key, nil, func() (string, error) {
|
||||
return d.runCommandHelm(cmd...)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DataLayer) RevisionManifestsParsed(namespace string, chartName string, revision int) ([]*v1.Carp, error) {
|
||||
out, err := d.RevisionManifests(namespace, chartName, revision, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dec := yaml.NewDecoder(bytes.NewReader([]byte(out)))
|
||||
|
||||
res := make([]*v1.Carp, 0)
|
||||
var tmp interface{}
|
||||
for dec.Decode(&tmp) == nil {
|
||||
// k8s libs uses only JSON tags defined, say hello to https://github.com/go-yaml/yaml/issues/424
|
||||
// we can juggle it
|
||||
jsoned, err := json.Marshal(tmp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var doc v1.Carp
|
||||
err = json.Unmarshal(jsoned, &doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if doc.Kind == "" {
|
||||
log.Warnf("Manifest piece is not k8s resource: %s", jsoned)
|
||||
continue
|
||||
}
|
||||
|
||||
res = append(res, &doc)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (d *DataLayer) RevisionNotes(namespace string, chartName string, revision int, _ bool) (res string, err error) {
|
||||
cmd := []string{"get", "notes", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision)}
|
||||
key := CacheKeyRevNotes + "\v" + namespace + "\v" + chartName + "\v" + strconv.Itoa(revision)
|
||||
return d.Cache.String(key, nil, func() (string, error) {
|
||||
return d.runCommandHelm(cmd...)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DataLayer) RevisionValues(namespace string, chartName string, revision int, onlyUserDefined bool) (res string, err error) {
|
||||
cmd := []string{"get", "values", chartName, "--namespace", namespace, "--output", "yaml", "--revision", strconv.Itoa(revision)}
|
||||
|
||||
if !onlyUserDefined {
|
||||
cmd = append(cmd, "--all")
|
||||
}
|
||||
|
||||
key := CacheKeyRevValues + "\v" + namespace + "\v" + chartName + "\v" + strconv.Itoa(revision) + "\v" + fmt.Sprintf("%v", onlyUserDefined)
|
||||
return d.Cache.String(key, nil, func() (string, error) {
|
||||
return d.runCommandHelm(cmd...)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DataLayer) ReleaseUninstall(namespace string, name string) error {
|
||||
d.Cache.Invalidate(CacheKeyRelList, cacheTagRelease(namespace, name))
|
||||
|
||||
_, err := d.runCommandHelm("uninstall", name, "--namespace", namespace)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DataLayer) Rollback(namespace string, name string, rev int) error {
|
||||
d.Cache.Invalidate(CacheKeyRelList, cacheTagRelease(namespace, name))
|
||||
|
||||
_, err := d.runCommandHelm("rollback", name, strconv.Itoa(rev), "--namespace", namespace)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DataLayer) ChartInstall(namespace string, name string, repoChart string, version string, justTemplate bool, values string, reuseVals bool) (string, error) {
|
||||
if values == "" && reuseVals {
|
||||
oldVals, err := d.RevisionValues(namespace, name, 0, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values = oldVals
|
||||
}
|
||||
|
||||
valsFile, close1, err := utils.TempFile(values)
|
||||
defer close1()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := []string{"upgrade", "--install", "--create-namespace", name, repoChart, "--version", version, "--namespace", namespace, "--values", valsFile, "--output", "json"}
|
||||
if justTemplate {
|
||||
cmd = append(cmd, "--dry-run")
|
||||
}
|
||||
|
||||
out, err := d.runCommandHelm(cmd...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !justTemplate {
|
||||
d.Cache.Invalidate(CacheKeyRelList, cacheTagRelease(namespace, name))
|
||||
}
|
||||
|
||||
res := release.Release{}
|
||||
err = json.Unmarshal([]byte(out), &res)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if justTemplate {
|
||||
out = strings.TrimSpace(res.Manifest)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func RevisionDiff(functor SectionFn, ext string, namespace string, name string, revision1 int, revision2 int, flag bool) (string, error) {
|
||||
if revision1 == 0 || revision2 == 0 {
|
||||
log.Debugf("One of revisions is zero: %d %d", revision1, revision2)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
manifest1, err := functor(namespace, name, revision1, flag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
manifest2, err := functor(namespace, name, revision2, flag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
diff := GetDiff(manifest1, manifest2, strconv.Itoa(revision1)+ext, strconv.Itoa(revision2)+ext)
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
func GetDiff(text1 string, text2 string, name1 string, name2 string) string {
|
||||
edits := myers.ComputeEdits(span.URIFromPath(""), text1, text2)
|
||||
unified := gotextdiff.ToUnified(name1, name2, text1, edits)
|
||||
diff := fmt.Sprint(unified)
|
||||
log.Debugf("The diff is: %s", diff)
|
||||
return diff
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -105,10 +104,9 @@ func RunCommand(cmd []string, env map[string]string) (string, error) {
|
||||
type QueryProps struct {
|
||||
Namespace string
|
||||
Name string
|
||||
Revision int
|
||||
}
|
||||
|
||||
func GetQueryProps(c *gin.Context, revRequired bool) (*QueryProps, error) {
|
||||
func GetQueryProps(c *gin.Context) (*QueryProps, error) {
|
||||
qp := QueryProps{}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
cRev, err := strconv.Atoi(c.Query("revision"))
|
||||
if err != nil && revRequired {
|
||||
return nil, err
|
||||
}
|
||||
qp.Revision = cRev
|
||||
|
||||
return &qp, nil
|
||||
}
|
||||
|
||||
@@ -12,31 +12,21 @@ func TestGetQueryProps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
revRequired bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Get query props - all set with revRequired true",
|
||||
wantErr: false,
|
||||
revRequired: true,
|
||||
endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&namespace=testing&revision=1",
|
||||
},
|
||||
{
|
||||
name: "Get query props - no revision with revRequired true",
|
||||
wantErr: true,
|
||||
revRequired: true,
|
||||
endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&namespace=testing",
|
||||
},
|
||||
{
|
||||
name: "Get query props - no namespace with revRequired true",
|
||||
wantErr: false,
|
||||
revRequired: true,
|
||||
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()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", tt.endpoint, nil)
|
||||
_, err := GetQueryProps(c, tt.revRequired)
|
||||
_, err := GetQueryProps(c)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GetQueryProps() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: "dashboard"
|
||||
version: "0.2.8"
|
||||
version: "0.3.1"
|
||||
usage: "A simplified way of working with Helm"
|
||||
description: "View HELM situation in nice web UI"
|
||||
command: "$HELM_PLUGIN_DIR/bin/helm-dashboard"
|
||||
|
||||
BIN
screenshot_run_test.png
Executable file
BIN
screenshot_run_test.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
screenshot_run_test_result.png
Executable file
BIN
screenshot_run_test_result.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -2,16 +2,23 @@
|
||||
|
||||
# Copied w/ love from the chartmuseum/helm-push :)
|
||||
|
||||
[ -z "$HELM_DEBUG" ] || set -x
|
||||
|
||||
name="helm-dashboard"
|
||||
repo="https://github.com/komodorio/${name}"
|
||||
api_repo="https://api.github.com/repos/komodorio/${name}/releases/latest"
|
||||
|
||||
if [ -n "${HELM_PUSH_PLUGIN_NO_INSTALL_HOOK}" ]; then
|
||||
echo "Development mode: not downloading versioned release."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
version="$(curl -s ${api_repo} | grep '\"name\": "v.*\"' | cut -d 'v' -f 2 | cut -d '"' -f 1)"
|
||||
echo Tried to autodetect latest version: $version
|
||||
[ -z "$version" ] && {
|
||||
version="$(cat plugin.yaml | grep "version" | cut -d '"' -f 2)"
|
||||
# TODO: if no version provided, get it from https://api.github.com/repos/komodorio/helm-dashboard/releases/latest
|
||||
echo Defaulted to version: $version
|
||||
}
|
||||
echo "Downloading and installing ${name} v${version} ..."
|
||||
|
||||
url=""
|
||||
|
||||
Reference in New Issue
Block a user