Support working with local charts (#215)

* Basic functioning

* Support reconfiguring

* Improve tests coverage

* Always update local repo, don't offer to delete it

* Handle multi-repo correctly

* Document local charts usage

* Screenshot for docs
This commit is contained in:
Andrey Pokhilko
2023-02-15 16:45:28 +00:00
committed by GitHub
parent 6a4ca793c9
commit f49f52efe4
17 changed files with 275 additions and 170 deletions

View File

@@ -1,14 +1,10 @@
<!-- 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 --> ## Changes Proposed
## Fixes Issue <!-- Describe the proposed changes and any additional information -->
<!-- Remove this section if not applicable --> <!-- Add all the screenshots which illustrate your changes -->
<!-- Example: Closes #31 --> ## Check List
## 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 --> <!-- Mark all the applicable boxes. To mark the box as done follow the following conventions -->
<!-- <!--
@@ -18,18 +14,8 @@
[ ] - Not correct; marked as **not** done [ ] - Not correct; marked as **not** done
--> -->
## Check List (Check all the applicable boxes) <!-- Follow the above conventions to check the box --> - [ ] The title of my pull request is a short description of the changes
- [ ] This PR relates to some issue: <!-- use "Closes #999" to auto-close related issue -->
- [ ] I have documented the changes made (if applicable)
- [ ] I have covered the changes with unit tests
- [ ] My code follows the code style of this project.
- [ ] My change requires changes to the documentation.
- [ ] I have updated the documentation accordingly.
- [ ] All new and existing tests passed.
- [ ] The title of my pull request is a short description of the requested changes.
## Screenshots
<!-- Add all the screenshots which support your changes -->
## Note to reviewers
<!-- Add notes to reviewers if applicable -->

View File

@@ -13,7 +13,7 @@
![GitHub contributors](https://img.shields.io/github/contributors/komodorio/helm-dashboard) [![GitHub issues](https://img.shields.io/github/issues-raw/komodorio/helm-dashboard)](https://github.com/komodorio/helm-dashboard/issues) ![GitHub stars](https://img.shields.io/github/stars/komodorio/helm-dashboard?style=social) ![GitHub closed issues](https://img.shields.io/github/issues-closed-raw/komodorio/helm-dashboard) ![GitHub pull requests](https://img.shields.io/github/issues-pr/komodorio/helm-dashboard) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/komodorio/helm-dashboard) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/komodorio/helm-dashboard) [![GitHub license](https://img.shields.io/github/license/komodorio/helm-dashboard)](https://github.com/komodorio/helm-dashboard) ![GitHub contributors](https://img.shields.io/github/contributors/komodorio/helm-dashboard) [![GitHub issues](https://img.shields.io/github/issues-raw/komodorio/helm-dashboard)](https://github.com/komodorio/helm-dashboard/issues) ![GitHub stars](https://img.shields.io/github/stars/komodorio/helm-dashboard?style=social) ![GitHub closed issues](https://img.shields.io/github/issues-closed-raw/komodorio/helm-dashboard) ![GitHub pull requests](https://img.shields.io/github/issues-pr/komodorio/helm-dashboard) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/komodorio/helm-dashboard) ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/komodorio/helm-dashboard) [![GitHub license](https://img.shields.io/github/license/komodorio/helm-dashboard)](https://github.com/komodorio/helm-dashboard)
<kbd>[<img src="screenshot.png" style="width: 100%; border: 1px solid silver;" border="1" alt="Screenshot">](screenshot.png)</kbd> <kbd>[<img src="images/screenshot.png" style="width: 100%; border: 1px solid silver;" border="1" alt="Screenshot">](images/screenshot.png)</kbd>
## Description ## Description
@@ -96,29 +96,44 @@ If you want to increase the logging verbosity and see all the debug info, use th
The official helm chart is [available here](https://github.com/komodorio/helm-charts/blob/master/charts/helm-dashboard) The official helm chart is [available here](https://github.com/komodorio/helm-charts/blob/master/charts/helm-dashboard)
## Selected Features
## Execute Helm tests ### Support for Local Charts
Local Helm chart is a directory with specially named files and a `Chart.yaml` file, which you can install via Helm, without the need to publish the chart into Helm repository. Chart developers might want to experiment with the chart locally, before uploading into public repository. Also, a proprietary application might only use non-published chart as an approach to deploy the software.
For all the above use-cases, you may use Helm Dashboard UI, spcifying location of your local chart folders via special `--local-chart` command-line parameter. The parameter might be specified multiple times, for example:
```shell
helm-dashboard --local-chart=/opt/charts/my-private-app --local-chart=/home/dev/sources/app/chart
```
When _valid_ local chart sources specified, the repository list would contain a surrogate `[local]` entry, with those charts listed inside. All the chart operations are normal: installing, reconfiguring and upgrading.
![](images/screenshot_local_charts.png)
### 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. 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: You can execute `helm test` for the specific release as below:
![](screenshot_run_test.png) ![](images/screenshot_run_test.png)
The result of executed `helm test` for the release will be disapled as below: The result of executed `helm test` for the release will be disapled as below:
![](screenshot_run_test_result.png) ![](images/screenshot_run_test_result.png)
## Scanner Integrations ### Scanner Integrations
Upon startup, Helm Dashboard detects the presence of [Trivy](https://github.com/aquasecurity/trivy) Upon startup, Helm Dashboard detects the presence of [Trivy](https://github.com/aquasecurity/trivy)
and [Checkov](https://github.com/bridgecrewio/checkov) scanners. When available, these scanners are offered on k8s and [Checkov](https://github.com/bridgecrewio/checkov) scanners. When available, these scanners are offered on k8s
resources page, as well as install/upgrade preview page. resources page, as well as install/upgrade preview page.
You can request scanning of the specific k8s resource in your cluster: You can request scanning of the specific k8s resource in your cluster:
![](screenshot_scan_resource.png) ![](images/screenshot_scan_resource.png)
If you want to validate the k8s manifest prior to installing/reconfiguring a Helm chart, look for "Scan for Problems" If you want to validate the k8s manifest prior to installing/reconfiguring a Helm chart, look for "Scan for Problems"
button at the bottom of the dialog: button at the bottom of the dialog:
![](screenshot_scan_manifest.png) ![](images/screenshot_scan_manifest.png)
## Support Channels ## Support Channels

View File

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -25,11 +25,12 @@ type options struct {
Version bool `long:"version" description:"Show tool version"` Version bool `long:"version" description:"Show tool version"`
Verbose bool `short:"v" long:"verbose" description:"Show verbose debug information"` Verbose bool `short:"v" long:"verbose" description:"Show verbose debug information"`
NoBrowser bool `short:"b" long:"no-browser" description:"Do not attempt to open Web browser upon start"` NoBrowser bool `short:"b" long:"no-browser" description:"Do not attempt to open Web browser upon start"`
NoTracking bool `long:"no-analytics" description:"Disable user analytics (GA, DataDog etc.)"` NoTracking bool `long:"no-analytics" description:"Disable user analytics (Heap, DataDog etc.)"`
BindHost string `long:"bind" description:"Host binding to start server (default: localhost)"` // default should be printed but not assigned as the precedence: flag > env > default BindHost string `long:"bind" description:"Host binding to start server (default: localhost)"` // default should be printed but not assigned as the precedence: flag > env > default
Port uint `short:"p" long:"port" description:"Port to start server on" default:"8080"` 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"` Namespace string `short:"n" long:"namespace" description:"Namespace for HELM operations"`
Devel bool `long:"devel" description:"Include development versions of charts"` Devel bool `long:"devel" description:"Include development versions of charts"`
LocalChart []string `long:"local-chart" description:"Specify location of local chart to include into UI"`
} }
func main() { func main() {
@@ -52,6 +53,7 @@ func main() {
Debug: opts.Verbose, Debug: opts.Verbose,
NoTracking: opts.NoTracking, NoTracking: opts.NoTracking,
Devel: opts.Devel, Devel: opts.Devel,
LocalCharts: opts.LocalChart,
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())

View File

@@ -3,11 +3,13 @@ package handlers
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"github.com/hexops/gotextdiff" "github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers" "github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span" "github.com/hexops/gotextdiff/span"
"github.com/joomcode/errorx" "github.com/joomcode/errorx"
"github.com/komodorio/helm-dashboard/pkg/dashboard/objects" "github.com/komodorio/helm-dashboard/pkg/dashboard/objects"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
"github.com/rogpeppe/go-internal/semver" "github.com/rogpeppe/go-internal/semver"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -15,12 +17,11 @@ import (
"helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/repo" "helm.sh/helm/v3/pkg/repo"
helmtime "helm.sh/helm/v3/pkg/time" helmtime "helm.sh/helm/v3/pkg/time"
"k8s.io/utils/strings/slices"
"net/http" "net/http"
"sort" "sort"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
) )
type HelmHandler struct { type HelmHandler struct {
@@ -162,6 +163,7 @@ func (h *HelmHandler) RepoVersions(c *gin.Context) {
AppVersion: r.AppVersion, AppVersion: r.AppVersion,
Description: r.Description, Description: r.Description,
Repository: r.Annotations[objects.AnnRepo], Repository: r.Annotations[objects.AnnRepo],
URLs: r.URLs,
}) })
} }
@@ -194,6 +196,7 @@ func (h *HelmHandler) RepoLatestVer(c *gin.Context) {
AppVersion: r.AppVersion, AppVersion: r.AppVersion,
Description: r.Description, Description: r.Description,
Repository: r.Annotations[objects.AnnRepo], Repository: r.Annotations[objects.AnnRepo],
URLs: r.URLs,
}) })
} }
@@ -288,12 +291,19 @@ func (h *HelmHandler) Install(c *gin.Context) {
return return
} }
repoChart, err := h.checkLocalRepo(c.PostForm("chart"))
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
justTemplate := c.PostForm("preview") == "true" justTemplate := c.PostForm("preview") == "true"
ns := c.Param("ns") ns := c.Param("ns")
if ns == "[empty]" { if ns == "[empty]" {
ns = "" ns = ""
} }
rel, err := app.Releases.Install(ns, c.PostForm("name"), c.PostForm("chart"), c.PostForm("version"), justTemplate, values)
rel, err := app.Releases.Install(ns, c.PostForm("name"), repoChart, c.PostForm("version"), justTemplate, values)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -306,6 +316,16 @@ func (h *HelmHandler) Install(c *gin.Context) {
} }
} }
func (h *HelmHandler) checkLocalRepo(repoChart string) (string, error) {
if strings.HasPrefix(repoChart, "file://") {
repoChart = repoChart[len("file://"):]
if !slices.Contains(h.Data.LocalCharts, repoChart) {
return "", fmt.Errorf("chart path is not present in local charts: %s", repoChart)
}
}
return repoChart, nil
}
func (h *HelmHandler) Upgrade(c *gin.Context) { func (h *HelmHandler) Upgrade(c *gin.Context) {
app := h.GetApp(c) app := h.GetApp(c)
if app == nil { if app == nil {
@@ -325,8 +345,14 @@ func (h *HelmHandler) Upgrade(c *gin.Context) {
return return
} }
repoChart, err := h.checkLocalRepo(c.PostForm("chart"))
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
justTemplate := c.PostForm("preview") == "true" justTemplate := c.PostForm("preview") == "true"
rel, err := existing.Upgrade(c.PostForm("chart"), c.PostForm("version"), justTemplate, values) rel, err := existing.Upgrade(repoChart, c.PostForm("version"), justTemplate, values)
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -409,7 +435,13 @@ func (h *HelmHandler) RepoValues(c *gin.Context) {
return // sets error inside return // sets error inside
} }
out, err := app.Repositories.GetChartValues(c.Query("chart"), c.Query("version")) repoChart, err := h.checkLocalRepo(c.Query("chart"))
if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
out, err := app.Repositories.GetChartValues(repoChart, c.Query("version"))
if err != nil { if err != nil {
_ = c.AbortWithError(http.StatusInternalServerError, err) _ = c.AbortWithError(http.StatusInternalServerError, err)
return return
@@ -433,8 +465,8 @@ func (h *HelmHandler) RepoList(c *gin.Context) {
out := []RepositoryElement{} out := []RepositoryElement{}
for _, r := range repos { for _, r := range repos {
out = append(out, RepositoryElement{ out = append(out, RepositoryElement{
Name: r.Orig.Name, Name: r.Name(),
URL: r.Orig.URL, URL: r.URL(),
}) })
} }
@@ -521,7 +553,7 @@ func (h *HelmHandler) handleGetSection(rel *objects.Release, section string, rDi
return res, nil return res, nil
} }
type RepoChartElement struct { type RepoChartElement struct { // TODO: do we need it at all? there is existing repo.ChartVersion in Helm
Name string `json:"name"` Name string `json:"name"`
Version string `json:"version"` Version string `json:"version"`
AppVersion string `json:"app_version"` AppVersion string `json:"app_version"`
@@ -530,6 +562,7 @@ type RepoChartElement struct {
InstalledNamespace string `json:"installed_namespace"` InstalledNamespace string `json:"installed_namespace"`
InstalledName string `json:"installed_name"` InstalledName string `json:"installed_name"`
Repository string `json:"repository"` Repository string `json:"repository"`
URLs []string `json:"urls"`
} }
func HReleaseToJSON(o *release.Release) *ReleaseElement { func HReleaseToJSON(o *release.Release) *ReleaseElement {

View File

@@ -32,6 +32,7 @@ type DataLayer struct {
appPerContext map[string]*Application appPerContext map[string]*Application
appPerContextMx *sync.Mutex appPerContextMx *sync.Mutex
devel bool devel bool
LocalCharts []string
} }
type StatusInfo struct { type StatusInfo struct {
@@ -170,6 +171,8 @@ func (d *DataLayer) AppForCtx(ctx string) (*Application, error) {
return nil, errorx.Decorate(err, "Failed to create application for context '%s'", ctx) return nil, errorx.Decorate(err, "Failed to create application for context '%s'", ctx)
} }
a.Repositories.LocalCharts = d.LocalCharts
app = a app = a
d.appPerContext[ctx] = app d.appPerContext[ctx] = app
} }
@@ -218,7 +221,7 @@ func (d *DataLayer) loopUpdateRepos(ctx context.Context, interval time.Duration)
for _, repo := range repos { for _, repo := range repos {
err := repo.Update() err := repo.Update()
if err != nil { if err != nil {
log.Warnf("Failed to update repo %s: %v", repo.Orig.Name, err) log.Warnf("Failed to update repo %s: %v", repo.Name(), err)
} }
} }
} }

View File

@@ -11,7 +11,6 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/getter"
@@ -26,9 +25,10 @@ type Repositories struct {
HelmConfig *action.Configuration HelmConfig *action.Configuration
mx sync.Mutex mx sync.Mutex
versionConstraint *semver.Constraints versionConstraint *semver.Constraints
LocalCharts []string
} }
func (r *Repositories) Load() (*repo.File, error) { func (r *Repositories) load() (*repo.File, error) {
r.mx.Lock() r.mx.Lock()
defer r.mx.Unlock() defer r.mx.Unlock()
@@ -40,20 +40,28 @@ func (r *Repositories) Load() (*repo.File, error) {
return f, nil return f, nil
} }
func (r *Repositories) List() ([]*Repository, error) { func (r *Repositories) List() ([]Repository, error) {
f, err := r.Load() f, err := r.load()
if err != nil { if err != nil {
return nil, errorx.Decorate(err, "failed to load repo information") return nil, errorx.Decorate(err, "failed to load repo information")
} }
res := []*Repository{} res := []Repository{}
for _, item := range f.Repositories { for _, item := range f.Repositories {
res = append(res, &Repository{ res = append(res, &HelmRepo{
Settings: r.Settings, Settings: r.Settings,
Orig: item, Orig: item,
versionConstraint: r.versionConstraint,
}) })
} }
if len(r.LocalCharts) > 0 {
lc := LocalChart{
LocalCharts: r.LocalCharts,
}
res = append(res, &lc)
}
return res, nil return res, nil
} }
@@ -71,7 +79,7 @@ func (r *Repositories) Add(name string, url string) error {
return err return err
} }
f, err := r.Load() f, err := r.load()
if err != nil { if err != nil {
return errorx.Decorate(err, "Failed to load repo config") return errorx.Decorate(err, "Failed to load repo config")
} }
@@ -114,7 +122,7 @@ func (r *Repositories) Add(name string, url string) error {
} }
func (r *Repositories) Delete(name string) error { func (r *Repositories) Delete(name string) error {
f, err := r.Load() f, err := r.load()
if err != nil { if err != nil {
return errorx.Decorate(err, "failed to load repo information") return errorx.Decorate(err, "failed to load repo information")
} }
@@ -136,25 +144,22 @@ func (r *Repositories) Delete(name string) error {
return nil return nil
} }
func (r *Repositories) Get(name string) (*Repository, error) { func (r *Repositories) Get(name string) (Repository, error) {
f, err := r.Load() l, err := r.List()
if err != nil { if err != nil {
return nil, errorx.Decorate(err, "failed to load repo information") return nil, errorx.Decorate(err, "failed to get list of repos")
} }
for _, entry := range f.Repositories { for _, entry := range l {
if entry.Name == name { if entry.Name() == name {
return &Repository{ return entry, nil
Settings: r.Settings,
Orig: entry,
versionConstraint: r.versionConstraint,
}, nil
} }
} }
return nil, errorx.DataUnavailable.New("Could not find reposiroty '%s'", name) return nil, errorx.DataUnavailable.New("Could not find repository '%s'", name)
} }
// Containing returns list of chart versions for the given chart name, across all repositories
func (r *Repositories) Containing(name string) (repo.ChartVersions, error) { func (r *Repositories) Containing(name string) (repo.ChartVersions, error) {
list, err := r.List() list, err := r.List()
if err != nil { if err != nil {
@@ -165,7 +170,7 @@ func (r *Repositories) Containing(name string) (repo.ChartVersions, error) {
for _, rep := range list { for _, rep := range list {
vers, err := rep.ByName(name) vers, err := rep.ByName(name)
if err != nil { if err != nil {
log.Warnf("Failed to get data from repo '%s', updating it might help", rep.Orig.Name) log.Warnf("Failed to get data from repo '%s', updating it might help", rep.Name())
log.Debugf("The error was: %v", err) log.Debugf("The error was: %v", err)
continue continue
} }
@@ -178,7 +183,7 @@ func (r *Repositories) Containing(name string) (repo.ChartVersions, error) {
v.Annotations = map[string]string{} v.Annotations = map[string]string{}
} }
v.Annotations[AnnRepo] = rep.Orig.Name v.Annotations[AnnRepo] = rep.Name()
// Validate the versions against semantic version constraints and filter // Validate the versions against semantic version constraints and filter
version, err := semver.NewVersion(v.Version) version, err := semver.NewVersion(v.Version)
@@ -199,24 +204,6 @@ func (r *Repositories) Containing(name string) (repo.ChartVersions, error) {
return res, nil 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) { func (r *Repositories) GetChartValues(chart string, ver string) (string, error) {
// comes from cmd/helm/show.go // comes from cmd/helm/show.go
client := action.NewShowWithConfig(action.ShowValues, r.HelmConfig) client := action.NewShowWithConfig(action.ShowValues, r.HelmConfig)
@@ -234,7 +221,15 @@ func (r *Repositories) GetChartValues(chart string, ver string) (string, error)
return out, nil return out, nil
} }
type Repository struct { type Repository interface {
Name() string
URL() string
Update() error
Charts() (repo.ChartVersions, error)
ByName(name string) (repo.ChartVersions, error)
}
type HelmRepo struct {
Settings *cli.EnvSettings Settings *cli.EnvSettings
Orig *repo.Entry Orig *repo.Entry
mx sync.Mutex mx sync.Mutex
@@ -242,11 +237,19 @@ type Repository struct {
versionConstraint *semver.Constraints versionConstraint *semver.Constraints
} }
func (r *Repository) indexFileName() string { func (r *HelmRepo) Name() string {
return r.Orig.Name
}
func (r *HelmRepo) URL() string {
return r.Orig.URL
}
func (r *HelmRepo) indexFileName() string {
return filepath.Join(r.Settings.RepositoryCache, helmpath.CacheIndexFile(r.Orig.Name)) return filepath.Join(r.Settings.RepositoryCache, helmpath.CacheIndexFile(r.Orig.Name))
} }
func (r *Repository) getIndex() (*repo.IndexFile, error) { func (r *HelmRepo) getIndex() (*repo.IndexFile, error) {
r.mx.Lock() r.mx.Lock()
defer r.mx.Unlock() defer r.mx.Unlock()
@@ -260,13 +263,13 @@ func (r *Repository) getIndex() (*repo.IndexFile, error) {
return ind, nil return ind, nil
} }
func (r *Repository) Charts() ([]*repo.ChartVersion, error) { func (r *HelmRepo) Charts() (repo.ChartVersions, error) {
ind, err := r.getIndex() ind, err := r.getIndex()
if err != nil { if err != nil {
return nil, errorx.Decorate(err, "failed to get repo index") return nil, errorx.Decorate(err, "failed to get repo index")
} }
res := []*repo.ChartVersion{} res := repo.ChartVersions{}
for _, cv := range ind.Entries { for _, cv := range ind.Entries {
for _, v := range cv { for _, v := range cv {
version, err := semver.NewVersion(v.Version) version, err := semver.NewVersion(v.Version)
@@ -292,7 +295,7 @@ func (r *Repository) Charts() ([]*repo.ChartVersion, error) {
return res, nil return res, nil
} }
func (r *Repository) ByName(name string) (repo.ChartVersions, error) { func (r *HelmRepo) ByName(name string) (repo.ChartVersions, error) {
ind, err := r.getIndex() ind, err := r.getIndex()
if err != nil { if err != nil {
return nil, errorx.Decorate(err, "failed to get repo index") return nil, errorx.Decorate(err, "failed to get repo index")
@@ -305,7 +308,7 @@ func (r *Repository) ByName(name string) (repo.ChartVersions, error) {
return repo.ChartVersions{}, nil return repo.ChartVersions{}, nil
} }
func (r *Repository) Update() error { func (r *HelmRepo) Update() error {
r.mx.Lock() r.mx.Lock()
defer r.mx.Unlock() defer r.mx.Unlock()
log.Infof("Updating repository: %s", r.Orig.Name) log.Infof("Updating repository: %s", r.Orig.Name)
@@ -366,3 +369,59 @@ func versionConstaint(isDevelEnabled bool) (*semver.Constraints, error) {
return constraint, nil return constraint, nil
} }
type LocalChart struct {
LocalCharts []string
charts map[string]repo.ChartVersions
mx sync.Mutex
}
// Update reloads the chart information from disk
func (l *LocalChart) Update() error {
l.mx.Lock()
defer l.mx.Unlock()
l.charts = map[string]repo.ChartVersions{}
for _, lc := range l.LocalCharts {
c, err := loader.Load(lc)
if err != nil {
log.Warnf("Failed to load chart from '%s': %s", lc, err)
continue
}
// we don't filter out dev versions here, because local chart implies user wants to see the chart anyway
l.charts[c.Name()] = repo.ChartVersions{&repo.ChartVersion{
URLs: []string{l.URL() + lc},
Metadata: c.Metadata,
}}
}
return nil
}
func (l *LocalChart) Name() string {
return "[local]"
}
func (l *LocalChart) URL() string {
return "file://"
}
func (l *LocalChart) Charts() (repo.ChartVersions, error) {
_ = l.Update() // always re-read, for chart devs to have quick debug loop
res := repo.ChartVersions{}
for _, c := range l.charts {
res = append(res, c...)
}
return res, nil
}
func (l *LocalChart) ByName(name string) (repo.ChartVersions, error) {
_ = l.Update() // always re-read, for chart devs to have quick debug loop
for n, c := range l.charts {
if n == name {
return c, nil
}
}
return repo.ChartVersions{}, nil
}

View File

@@ -57,67 +57,61 @@ func initRepository(t *testing.T, filePath string, devel bool) *Repositories {
Settings: settings, Settings: settings,
HelmConfig: &action.Configuration{}, // maybe use copy of getFakeHelmConfig from api_test.go HelmConfig: &action.Configuration{}, // maybe use copy of getFakeHelmConfig from api_test.go
versionConstraint: vc, versionConstraint: vc,
LocalCharts: []string{"../../../charts/helm-dashboard"},
} }
return testRepository return testRepository
} }
func TestList(t *testing.T) { func TestFlow(t *testing.T) {
testRepository := initRepository(t, validRepositoryConfigPath, false) testRepository := initRepository(t, validRepositoryConfigPath, false)
// initial list
repos, err := testRepository.List() repos, err := testRepository.List()
if err != nil { assert.NilError(t, err)
t.Fatal(err) assert.Equal(t, len(repos), 5)
}
assert.Equal(t, len(repos), 4)
}
func TestAdd(t *testing.T) {
testRepoName := "TEST" testRepoName := "TEST"
testRepoUrl := "https://helm.github.io/examples" testRepoUrl := "https://helm.github.io/examples"
testRepository := initRepository(t, validRepositoryConfigPath, false) // add repo
err := testRepository.Add(testRepoName, testRepoUrl) err = testRepository.Add(testRepoName, testRepoUrl)
if err != nil { assert.NilError(t, err)
t.Fatal(err, "Failed to add repo")
}
// get repo
r, err := testRepository.Get(testRepoName) r, err := testRepository.Get(testRepoName)
if err != nil { assert.NilError(t, err)
t.Fatal(err, "Failed to add repo") assert.Equal(t, r.URL(), testRepoUrl)
}
assert.Equal(t, r.Orig.URL, testRepoUrl) // update repo
} err = r.Update()
assert.NilError(t, err)
func TestDelete(t *testing.T) { // list charts
testRepository := initRepository(t, validRepositoryConfigPath, false) c, err := r.Charts()
assert.NilError(t, err)
testRepoName := "charts" // don't ever delete 'testing'! // contains chart
err := testRepository.Delete(testRepoName) c, err = testRepository.Containing(c[0].Name)
if err != nil { assert.NilError(t, err)
t.Fatal(err, "Failed to delete the repo")
}
_, err = testRepository.Get(testRepoName) // chart by name from repo
if err == nil { c, err = r.ByName(c[0].Name)
t.Fatal("Failed to delete repo") assert.NilError(t, err)
}
}
func TestGet(t *testing.T) { // get chart values
// Initial repositiry name in test file v, err := testRepository.GetChartValues(r.Name()+"/"+c[0].Name, c[0].Version)
repoName := "charts" assert.NilError(t, err)
assert.Assert(t, v != "")
testRepository := initRepository(t, validRepositoryConfigPath, false) // delete added
err = testRepository.Delete(testRepoName)
assert.NilError(t, err)
repo, err := testRepository.Get(repoName) // final list
if err != nil { repos, err = testRepository.List()
t.Fatal(err, "Failed to get th repo") assert.NilError(t, err)
} assert.Equal(t, len(repos), 5)
assert.Equal(t, repo.Orig.Name, repoName)
} }
func TestRepository_Charts_DevelDisabled(t *testing.T) { func TestRepository_Charts_DevelDisabled(t *testing.T) {

View File

@@ -30,6 +30,7 @@ type Server struct {
Debug bool Debug bool
NoTracking bool NoTracking bool
Devel bool Devel bool
LocalCharts []string
} }
func (s *Server) StartServer(ctx context.Context, cancel context.CancelFunc) (string, utils.ControlChan, error) { func (s *Server) StartServer(ctx context.Context, cancel context.CancelFunc) (string, utils.ControlChan, error) {
@@ -38,6 +39,8 @@ func (s *Server) StartServer(ctx context.Context, cancel context.CancelFunc) (st
return "", nil, errorx.Decorate(err, "Failed to create data layer") return "", nil, errorx.Decorate(err, "Failed to create data layer")
} }
data.LocalCharts = s.LocalCharts
isDevModeWithAnalytics := os.Getenv("HD_DEV_ANALYTICS") == "true" isDevModeWithAnalytics := os.Getenv("HD_DEV_ANALYTICS") == "true"
data.StatusInfo.Analytics = (!s.NoTracking && s.Version != "0.0.0") || isDevModeWithAnalytics data.StatusInfo.Analytics = (!s.NoTracking && s.Version != "0.0.0") || isDevModeWithAnalytics

View File

@@ -56,13 +56,7 @@ function checkUpgradeable(name) {
function popUpUpgrade(elm, ns, name, verCur, lastRev) { function popUpUpgrade(elm, ns, name, verCur, lastRev) {
$("#upgradeModal .btn-confirm").prop("disabled", true) $("#upgradeModal .btn-confirm").prop("disabled", true)
let chart = elm.repository + "/" + elm.name; $('#upgradeModal').data("initial", !verCur)
if (!elm.name) {
chart = ""
}
$('#upgradeModal').data("chart", chart).data("initial", !verCur)
$('#upgradeModal form .chart-name').val(chart)
$('#upgradeModal').data("newManifest", "") $('#upgradeModal').data("newManifest", "")
$("#upgradeModalLabel .name").text(elm.name) $("#upgradeModalLabel .name").text(elm.name)
@@ -93,14 +87,17 @@ function popUpUpgrade(elm, ns, name, verCur, lastRev) {
$.getJSON("/api/helm/repositories/versions?name=" + elm.name).fail(function (xhr) { $.getJSON("/api/helm/repositories/versions?name=" + elm.name).fail(function (xhr) {
reportError("Failed to find chart in repo", xhr) reportError("Failed to find chart in repo", xhr)
}).done(function (vers) { }).done(function (vers) {
vers.sort((b, a) => (a.version > b.version) - (a.version < b.version))
// fill versions // fill versions
$('#upgradeModal select').empty() $('#upgradeModal select').empty()
for (let i = 0; i < vers.length; i++) { for (let i = 0; i < vers.length; i++) {
const opt = $("<option value='" + vers[i].version + "'></option>"); const opt = $("<option value='" + vers[i].version + "'></option>").data("ver", vers[i]);
const label = vers[i].repository + " @ " + vers[i].version;
if (vers[i].version === verCur) { if (vers[i].version === verCur) {
opt.html(vers[i].version + " &middot;") opt.html(label + " ")
} else { } else {
opt.html(vers[i].version) opt.html(label)
} }
$('#upgradeModal select').append(opt) $('#upgradeModal select').append(opt)
} }
@@ -162,9 +159,7 @@ function changeTimer() {
if (reconfigTimeout) { if (reconfigTimeout) {
window.clearTimeout(reconfigTimeout) window.clearTimeout(reconfigTimeout)
} }
reconfigTimeout = window.setTimeout(function () { reconfigTimeout = window.setTimeout(requestChangeDiff, 500)
requestChangeDiff()
}, 500)
} }
$("#upgradeModal textarea").keyup(changeTimer) $("#upgradeModal textarea").keyup(changeTimer)
@@ -173,12 +168,26 @@ $("#upgradeModal .rel-ns").keyup(changeTimer)
$('#upgradeModal select').change(function () { $('#upgradeModal select').change(function () {
const self = $(this) const self = $(this)
const ver = self.find("option:selected").data("ver");
let chart = ver.repository + "/" + ver.name;
if (!ver.name) {
chart = ""
}
// local chart case
if (ver.urls && ver.urls.length && ver.urls[0].startsWith("file://")) {
chart = ver.urls[0];
}
$('#upgradeModal').data("chart", chart)
$('#upgradeModal form .chart-name').val(chart)
requestChangeDiff() requestChangeDiff()
// fill reference values // fill reference values
$("#upgradeModal .ref-vals").html('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>') $("#upgradeModal .ref-vals").html('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
const chart = $("#upgradeModal").data("chart");
// TODO: if chart is empty, query different URL that will restore values without repo // TODO: if chart is empty, query different URL that will restore values without repo
if (chart) { if (chart) {
$.get("/api/helm/repositories/values?chart=" + chart + "&version=" + self.val()).fail(function (xhr) { $.get("/api/helm/repositories/values?chart=" + chart + "&version=" + self.val()).fail(function (xhr) {
@@ -231,7 +240,6 @@ $('#upgradeModal .btn-scan').click(function () {
}) })
function requestChangeDiff() { function requestChangeDiff() {
const self = $('#upgradeModal select');
const diffBody = $("#upgradeModalBody"); const diffBody = $("#upgradeModalBody");
diffBody.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Calculating diff...') diffBody.empty().append('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Calculating diff...')
$("#upgradeModal .btn-confirm").prop("disabled", true) $("#upgradeModal .btn-confirm").prop("disabled", true)
@@ -394,7 +402,7 @@ $("#btnAddRepository").click(function () {
window.location.reload() window.location.reload()
}) })
$("#btnTest").click(function() { $("#btnTest").click(function () {
const myModal = new bootstrap.Modal(document.getElementById('testModal'), {}); 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...') $("#testModal .test-result").empty().prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Waiting for completion...')
myModal.show() myModal.show()
@@ -406,7 +414,7 @@ $("#btnTest").click(function() {
myModal.hide() myModal.hide()
}).done(function (data) { }).done(function (data) {
var output; var output;
if(data.length == 0 || data == null || data == "") { if (data.length == 0 || data == null || data == "") {
output = "<div>Tests executed successfully<br><br><pre>Empty response from API<pre></div>" output = "<div>Tests executed successfully<br><br><pre>Empty response from API<pre></div>"
} else { } else {
output = data.replaceAll("\n", "<br>") output = data.replaceAll("\n", "<br>")

View File

@@ -479,7 +479,7 @@
<script src="static/actions.js"></script> <script src="static/actions.js"></script>
<script src="static/scripts.js"></script> <script src="static/scripts.js"></script>
<!-- BANNER START --> <!-- BANNER START
<a id="banner" <a id="banner"
href="https://helm-dashboard-survey.komodor.com/" 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 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
@@ -519,7 +519,7 @@
$("#banner").hide() $("#banner").hide()
}) })
</script> </script>
<!-- /BANNER END --> /BANNER END -->
</body> </body>
</html> </html>

View File

@@ -10,7 +10,7 @@ function loadRepoView() {
data.sort((a, b) => (a.name > b.name) - (a.name < b.name)) data.sort((a, b) => (a.name > b.name) - (a.name < b.name))
data.forEach(function (elm) { data.forEach(function (elm) {
let opt = $('<li class="mb-2"><label><input type="radio" name="cluster" class="me-2"/><span></span></label></li>'); let opt = $('<li class="mb-2"><label><input type="radio" name="repo" class="me-2"/><span></span></label></li>');
opt.attr('title', elm.url) opt.attr('title', elm.url)
opt.find("input").val(elm.name).text(elm.name).data("item", elm) opt.find("input").val(elm.name).text(elm.name).data("item", elm)
opt.find("span").text(elm.name) opt.find("span").text(elm.name)
@@ -30,6 +30,8 @@ function loadRepoView() {
$("#sectionRepo .repo-details h2").text(elm.name) $("#sectionRepo .repo-details h2").text(elm.name)
$("#sectionRepo .repo-details .url").text(elm.url) $("#sectionRepo .repo-details .url").text(elm.url)
$("#sectionRepo .btn-remove").prop("disabled", elm.url.startsWith('file://'))
$("#sectionRepo .repo-details ul").html('<span class="spinner-border spinner-border-sm mx-1" role="status" aria-hidden="true"></span>') $("#sectionRepo .repo-details ul").html('<span class="spinner-border spinner-border-sm mx-1" role="status" aria-hidden="true"></span>')
$.getJSON("/api/helm/repositories/" + elm.name).fail(function (xhr) { $.getJSON("/api/helm/repositories/" + elm.name).fail(function (xhr) {
reportError("Failed to get list of charts in repo", xhr) reportError("Failed to get list of charts in repo", xhr)