mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-24 03:38:04 +00:00
[WIP] Major release 1.0 (#147)
* Object model with self-sufficient binary (#131) * Code cosmetics * Experimenting with object model and direct HELM usage * Experiment with object model * replacing the kubectl * Progressing * Save the progress * Able to start with migration in mind * Migrated two pieces * List releases via Helm * Forgotten field * Cristallized the problem of ctx switcher * Reworked to multi-context * Rollback is also new style * More migration * Refactoring * Describe via code * Bye-bye kubectl binary * Eliminate more old code * Refactor a bit * Merges * No binaries in dockerfile * Commit * Progress with getting the data * Learned the thing about get * One field less * Sstart with repos * Repo add * repo remove * Repos! Icons! * Simplified access to data * Ver listing works * Ver check works * Caching and values * fixup * Done with repos * Working on install * Install work-ish * Fix UI failing on install * Upgrade flow works * Fix image building * Remove outdated test file * Move files around * REfactorings * Cosmetics * Test for cache control (#151) * Files import formatted * Added go-test tools * Added test for no-cache header * added changes * test for cache behaviour of app * test for static route (#153) * Tests: route configuration & context setter (#154) * Test for route configuration * Test for context setter middleware * implemented changes * Restore coverage profile Fixes #156 * Cosmetics * Test for `NewRouter` function (#157) * Test for `configureScanners` (#158) * Test for `configureKubectls` (#163) * Test for repository loading (#169) - Created `repos_test.go` - Test: `Load()` of Repositories * Build all PRs * Fixes failing test (#171) * Fixes failing test - Fixes failing test of repo loading * handles error for * Did some changes * Test for listing of repos (#173) - and did some code formatting Signed-off-by: OmAxiani0 <aximaniom@gmail.com> Signed-off-by: OmAxiani0 <aximaniom@gmail.com> * Test for adding repo (#175) - Modified the `repositories.yml` file Signed-off-by: OmAxiani0 <aximaniom@gmail.com> Signed-off-by: OmAxiani0 <aximaniom@gmail.com> * Test for deleting the repository (#176) * Test for deleting the repository - Also added cleanup function for `TestAdd` * Fixes failing test * Add auto labeler for PR's (#174) * Add auto labeler for PR's * Add all file under .github/workflow to 'ci' label Co-authored-by: Harshit Mehta <harshitm@nvidia.com> * Test for getting repository (#177) * Add github workflow for auto PR labeling (#181) Co-authored-by: Harshit Mehta <harshitm@nvidia.com> * Stub compilation * Fixes around installing * More complex test * Using object model to execute helm test (#191) * Expand test * More test * Coverage * Add mutex for operations * Rectore cluster detection code * Change receiver to pointer * Support multiple namespaces * Cosmetics * Update repos periodically * fix tests * Fix error display * Allow reconfiguring chart without repo * mute linter * Cosmetics * Failing approach to parse manifests Relates to #30 * Report the error properly * ✅ Add test for dashboard/objects/data.go NewDataLayer (#199) * Fix problem of wrong namespace * Added unit tests for releases (#204) * Rework API routes (#197) * Bootstrap OpenAPI doc * Renaming some routes * Listing namespaces * k8s part of things * Repositories section * Document scanners API * One more API call * Progress * Reworked install flow * History endpoint * Textual info section * Resources endpoint * Rollback endpoint * Rollback endpoint * Unit tests * Cleanup * Forgotten tags * Fix tests * TODOs * Rework manifest scanning * add hasTests flag * Adding more information on UI for helm test API response (#195) * Hide test button when no tests Fixes #115 Improves #195 --------- Signed-off-by: OmAxiani0 <aximaniom@gmail.com> Co-authored-by: Om Aximani <75031769+OmAximani0@users.noreply.github.com> Co-authored-by: Harshit Mehta <hdm23061993@gmail.com> Co-authored-by: Harshit Mehta <harshitm@nvidia.com> Co-authored-by: Todd Turner <todd@toddtee.sh> Co-authored-by: arvindsundararajan98 <109727359+arvindsundararajan98@users.noreply.github.com>
This commit is contained in:
@@ -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) {
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
if c.GetHeader("Cache-Control") == "" { // default policy is not to cache
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
return
|
||||
}
|
||||
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,40 +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}
|
||||
func configureHelms(api *gin.RouterGroup, data *objects.DataLayer) {
|
||||
h := handlers.HelmHandler{
|
||||
Contexted: &handlers.Contexted{
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
|
||||
api.GET("/charts", h.GetCharts)
|
||||
api.DELETE("/charts", h.Uninstall)
|
||||
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)
|
||||
|
||||
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/tests", h.Tests)
|
||||
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)
|
||||
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 *subproc.DataLayer) {
|
||||
h := handlers.KubeHandler{Data: data}
|
||||
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) {
|
||||
@@ -162,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)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
h.EnableClientCache(c)
|
||||
|
||||
rel := h.getRelease(c)
|
||||
if rel == nil {
|
||||
return // error state is set inside
|
||||
}
|
||||
|
||||
res, err := h.Data.RevisionManifestsParsed(qp.Namespace, qp.Name, qp.Revision)
|
||||
res, err := objects.ParseManifests(rel.Orig.Manifest)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.IndentedJSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (h *HelmHandler) RepoSearch(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
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.ChartRepoVersions(qp.Name)
|
||||
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) RepoLatestVer(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
app := h.GetApp(c)
|
||||
if app == nil {
|
||||
return // sets error inside
|
||||
}
|
||||
|
||||
rep, err := app.Repositories.Containing(qp.Name)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
res := []*RepoChartElement{}
|
||||
for _, r := range rep {
|
||||
res = append(res, &RepoChartElement{
|
||||
Name: r.Name,
|
||||
Version: r.Version,
|
||||
AppVersion: r.AppVersion,
|
||||
Description: r.Description,
|
||||
Repository: r.Annotations[objects.AnnRepo],
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(res, func(i, j int) bool {
|
||||
return semver.Compare(res[i].Version, res[j].Version) > 0
|
||||
})
|
||||
|
||||
if len(res) > 0 {
|
||||
c.IndentedJSON(http.StatusOK, res[:1])
|
||||
} else {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HelmHandler) RepoCharts(c *gin.Context) {
|
||||
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,80 +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)
|
||||
}
|
||||
|
||||
func (h *HelmHandler) Install(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
justTemplate := c.PostForm("preview") == "true"
|
||||
ns := c.Param("ns")
|
||||
if ns == "[empty]" {
|
||||
ns = ""
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
out = subproc.GetDiff(strings.TrimSpace(manifests), out, "current.yaml", "upgraded.yaml")
|
||||
c.IndentedJSON(http.StatusOK, rel)
|
||||
} else {
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.IndentedJSON(http.StatusAccepted, rel)
|
||||
}
|
||||
|
||||
c.String(http.StatusAccepted, out)
|
||||
}
|
||||
|
||||
func (h *HelmHandler) Tests(c *gin.Context) {
|
||||
qp, err := utils.GetQueryProps(c, false)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
func (h *HelmHandler) Upgrade(c *gin.Context) {
|
||||
app := h.GetApp(c)
|
||||
if app == nil {
|
||||
return // sets error inside
|
||||
}
|
||||
|
||||
out, err := h.Data.RunTests(qp.Namespace, qp.Name)
|
||||
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) {
|
||||
qp, err := utils.GetQueryProps(c, true)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusBadRequest, err)
|
||||
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
|
||||
}
|
||||
|
||||
flag := c.Query("flag") == "true"
|
||||
rDiff := c.Query("revisionDiff")
|
||||
res, err := handleGetSection(h.Data, c.Param("section"), rDiff, qp, flag)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
flag := c.Query("userDefined") == "true"
|
||||
|
||||
res, err := h.handleGetSection(rev, c.Param("section"), revDiff, flag)
|
||||
if err != nil {
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -210,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
|
||||
@@ -237,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
|
||||
@@ -251,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]
|
||||
@@ -263,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
|
||||
|
||||
err = s.detectClusterMode(data)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
go checkUpgrade(data.StatusInfo)
|
||||
|
||||
discoverScanners(&data)
|
||||
discoverScanners(data)
|
||||
|
||||
abort := make(utils.ControlChan)
|
||||
api := NewRouter(abort, &data, s.Debug)
|
||||
done := s.startBackgroundServer(api, abort)
|
||||
go data.PeriodicTasks(ctx)
|
||||
|
||||
return "http://" + s.Address, done
|
||||
api := NewRouter(cancel, data, s.Debug)
|
||||
done := s.startBackgroundServer(api, ctx)
|
||||
|
||||
return "http://" + s.Address, done, nil
|
||||
}
|
||||
|
||||
func (s Server) startBackgroundServer(routes *gin.Engine, abort utils.ControlChan) utils.ControlChan {
|
||||
func (s *Server) detectClusterMode(data *objects.DataLayer) error {
|
||||
data.StatusInfo.ClusterMode = os.Getenv("HD_CLUSTER_MODE") != ""
|
||||
if data.StatusInfo.ClusterMode {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctxs, err := data.ListContexts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(ctxs) == 0 {
|
||||
log.Infof("Got no kubectl config contexts, will attempt to detect if we're inside cluster...")
|
||||
app, err := data.AppForCtx("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns, err := app.K8s.GetNameSpaces()
|
||||
if err != nil { // no point in continuing without kubectl context and k8s connection
|
||||
return err
|
||||
}
|
||||
log.Debugf("Got %d namespaces listed", len(ns.Items))
|
||||
data.StatusInfo.ClusterMode = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) startBackgroundServer(routes *gin.Engine, ctx context.Context) utils.ControlChan {
|
||||
done := make(utils.ControlChan)
|
||||
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,53 +74,70 @@ 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) {
|
||||
reportError("Failed to find chart in repo", xhr)
|
||||
}).done(function (vers) {
|
||||
// fill versions
|
||||
$('#upgradeModal select').empty()
|
||||
for (let i = 0; i < vers.length; i++) {
|
||||
const opt = $("<option value='" + vers[i].version + "'></option>");
|
||||
if (vers[i].version === verCur) {
|
||||
opt.html(vers[i].version + " ·")
|
||||
} else {
|
||||
opt.html(vers[i].version)
|
||||
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
|
||||
$('#upgradeModal select').empty()
|
||||
for (let i = 0; i < vers.length; i++) {
|
||||
const opt = $("<option value='" + vers[i].version + "'></option>");
|
||||
if (vers[i].version === verCur) {
|
||||
opt.html(vers[i].version + " ·")
|
||||
} else {
|
||||
opt.html(vers[i].version)
|
||||
}
|
||||
$('#upgradeModal select').append(opt)
|
||||
}
|
||||
$('#upgradeModal select').append(opt)
|
||||
}
|
||||
|
||||
$('#upgradeModal select').val(elm.version).trigger("change")
|
||||
$('#upgradeModal select').val(elm.version).trigger("change").parent().show()
|
||||
upgrPopUpCommon(verCur, ns, lastRev, name)
|
||||
})
|
||||
} else { // chart without repo reconfigure
|
||||
$('#upgradeModal select').empty().trigger("change").parent().hide()
|
||||
upgrPopUpCommon(verCur, ns, lastRev, name)
|
||||
}
|
||||
}
|
||||
|
||||
const myModal = new bootstrap.Modal(document.getElementById('upgradeModal'), {});
|
||||
myModal.show()
|
||||
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) {
|
||||
reportError("Failed to get charts values info", xhr)
|
||||
}).done(function (data) {
|
||||
$("#upgradeModal textarea").val(data).data("dirty", false)
|
||||
})
|
||||
} else {
|
||||
$("#upgradeModal textarea").val("").data("dirty", true)
|
||||
}
|
||||
})
|
||||
if (verCur) {
|
||||
// fill current values
|
||||
$.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)
|
||||
})
|
||||
} 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) {
|
||||
reportError("Failed to get upgrade info", xhr)
|
||||
}).done(function (data) {
|
||||
data = hljs.highlight(data, {language: 'yaml'}).value
|
||||
$("#upgradeModal .ref-vals").html(data)
|
||||
})
|
||||
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,36 +252,52 @@ 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>")
|
||||
$("#upgradeModalBody").html("<p class='text-danger'>Failed to get upgrade info: " + xhr.responseText + "</p>")
|
||||
}).done(function (data) {
|
||||
diffBody.empty();
|
||||
$("#upgradeModal .btn-confirm").prop("disabled", false)
|
||||
$('#upgradeModal').data("newManifest", data.manifest)
|
||||
|
||||
const targetElement = document.getElementById('upgradeModalBody');
|
||||
const configuration = {
|
||||
inputFormat: 'diff', outputFormat: 'side-by-side',
|
||||
drawFileList: false, showFiles: false, highlight: true,
|
||||
};
|
||||
const diff2htmlUi = new Diff2HtmlUI(targetElement, data, configuration);
|
||||
diff2htmlUi.draw()
|
||||
if (!data) {
|
||||
diffBody.html("No changes will happen to the cluster")
|
||||
}
|
||||
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) {
|
||||
diffBody.empty();
|
||||
$("#upgradeModal .btn-confirm").prop("disabled", false)
|
||||
|
||||
const targetElement = document.getElementById('upgradeModalBody');
|
||||
const configuration = {
|
||||
inputFormat: 'diff', outputFormat: 'side-by-side',
|
||||
drawFileList: false, showFiles: false, highlight: true,
|
||||
};
|
||||
const diff2htmlUi = new Diff2HtmlUI(targetElement, data, configuration);
|
||||
diff2htmlUi.draw()
|
||||
if (!data) {
|
||||
diffBody.html("No changes will happen to the cluster")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function upgradeModalQstr() {
|
||||
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)
|
||||
@@ -345,16 +395,23 @@ $("#btnAddRepository").click(function () {
|
||||
})
|
||||
|
||||
$("#btnTest").click(function() {
|
||||
$("#testModal .test-result").empty().prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>')
|
||||
const myModal = new bootstrap.Modal(document.getElementById('testModal'), {});
|
||||
$("#testModal .test-result").empty().prepend('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Waiting for completion...')
|
||||
myModal.show()
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: "/api/helm/charts/tests" + "?namespace=" + getHashParam("namespace") + "&name=" + getHashParam("chart")
|
||||
url: "/api/helm/releases/" + getHashParam("namespace") + "/" + getHashParam("chart") + "/test"
|
||||
}).fail(function (xhr) {
|
||||
reportError("Failed to execute test for chart", xhr)
|
||||
myModal.hide()
|
||||
}).done(function (data) {
|
||||
$("#testModal .test-result").empty().html(data.replaceAll("\n", "<br>"))
|
||||
var output;
|
||||
if(data.length == 0 || data == null || data == "") {
|
||||
output = "<div>Tests executed successfully<br><br><pre>Empty response from API<pre></div>"
|
||||
} else {
|
||||
output = data.replaceAll("\n", "<br>")
|
||||
}
|
||||
$("#testModal .test-result").empty().html(output)
|
||||
myModal.show()
|
||||
})
|
||||
|
||||
const myModal = new bootstrap.Modal(document.getElementById('testModal'), {});
|
||||
myModal.show()
|
||||
})
|
||||
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);
|
||||
@@ -228,7 +226,7 @@ function getStatusMessage(status) {
|
||||
}
|
||||
if (status.conditions) {
|
||||
return status.conditions[0].message || status.conditions[0].reason
|
||||
}
|
||||
}
|
||||
return status.message || status.reason
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +195,7 @@
|
||||
<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"
|
||||
<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"
|
||||
@@ -367,14 +368,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):
|
||||
|
||||
@@ -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,43 +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
|
||||
let match = null
|
||||
// semver2 regex , add optional v prefix
|
||||
const chartNameRegex = 'v?(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?'
|
||||
if (!new RegExp(chartNameRegex).test(chartName)) {
|
||||
alert('Chart name does not match chart name regex.')
|
||||
} else {
|
||||
match = chartName.match(chartNameRegex);
|
||||
if (elm.icon) {
|
||||
card.find(".rel-name").attr("style", "background-image: url(" + elm.icon + ")")
|
||||
}
|
||||
|
||||
if (match) {
|
||||
chartName = elm.chart.substring(0, match.index - 1)
|
||||
} else {
|
||||
// fall back to simple substr
|
||||
chartName = elm.chart.substring(0, elm.chart.lastIndexOf("-"))
|
||||
if (elm.description) {
|
||||
card.find(".rel-name div").text(elm.description)
|
||||
}
|
||||
$.getJSON("/api/helm/repo/search?name=" + chartName).fail(function (xhr) {
|
||||
// we're ok if we can't show icon and description
|
||||
console.log("Failed to get repo name for charts", xhr)
|
||||
}).done(function (data) {
|
||||
if (data.length > 0) {
|
||||
$.getJSON("/api/helm/charts/show?name=" + data[0].name).fail(function (xhr) {
|
||||
console.log("Failed to get chart", xhr)
|
||||
}).done(function (data) {
|
||||
if (data) {
|
||||
const res = data[0];
|
||||
if (res.icon) {
|
||||
card.find(".rel-name").attr("style", "background-image: url(" + res.icon + ")")
|
||||
}
|
||||
if (res.description) {
|
||||
card.find(".rel-name div").text(res.description)
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
card.find(".rel-name span").text(elm.name)
|
||||
card.find(".rel-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,13 +2,13 @@ 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) {
|
||||
const items = $("#sectionRepo .repo-list ul").empty()
|
||||
data.sort((a, b) => (a.name > b.name) - (a.name < b.name))
|
||||
|
||||
|
||||
data.forEach(function (elm) {
|
||||
let opt = $('<li class="mb-2"><label><input type="radio" name="cluster" class="me-2"/><span></span></label></li>');
|
||||
opt.attr('title', elm.url)
|
||||
@@ -20,7 +20,7 @@ function loadRepoView() {
|
||||
if (!data.length) {
|
||||
items.text("No repositories found, try adding one")
|
||||
}
|
||||
sendStats('Get repo', {'status': 'success', length:data.length});
|
||||
sendStats('Get repo', {'status': 'success', length: data.length});
|
||||
items.find("input").click(function () {
|
||||
$("#inputSearch").val('')
|
||||
const self = $(this)
|
||||
@@ -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 contextNamespace = ctxFiltered.length?ctxFiltered[0].Namespace:""
|
||||
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)
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
package subproc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hexops/gotextdiff"
|
||||
"github.com/hexops/gotextdiff/myers"
|
||||
"github.com/hexops/gotextdiff/span"
|
||||
"github.com/komodorio/helm-dashboard/pkg/dashboard/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
"helm.sh/helm/v3/pkg/release"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/testapigroup/v1"
|
||||
)
|
||||
|
||||
type DataLayer struct {
|
||||
KubeContext string
|
||||
Helm string
|
||||
Kubectl string
|
||||
Scanners []Scanner
|
||||
StatusInfo *StatusInfo
|
||||
Namespace string
|
||||
Cache *Cache
|
||||
}
|
||||
|
||||
type StatusInfo struct {
|
||||
CurVer string
|
||||
LatestVer string
|
||||
Analytics bool
|
||||
LimitedToNamespace string
|
||||
CacheHitRatio float64
|
||||
ClusterMode bool
|
||||
}
|
||||
|
||||
func (d *DataLayer) runCommand(cmd ...string) (string, error) {
|
||||
for i, c := range cmd {
|
||||
// TODO: remove namespace parameter if it's empty
|
||||
if c == "--namespace" && i < len(cmd) { // TODO: in case it's not found - add it?
|
||||
d.forceNamespace(&cmd[i+1])
|
||||
}
|
||||
}
|
||||
|
||||
return utils.RunCommand(cmd, map[string]string{"HELM_KUBECONTEXT": d.KubeContext})
|
||||
}
|
||||
|
||||
func (d *DataLayer) runCommandHelm(cmd ...string) (string, error) {
|
||||
if d.Helm == "" {
|
||||
d.Helm = "helm"
|
||||
}
|
||||
|
||||
cmd = append([]string{d.Helm}, cmd...)
|
||||
if d.KubeContext != "" {
|
||||
cmd = append(cmd, "--kube-context", d.KubeContext)
|
||||
}
|
||||
|
||||
return d.runCommand(cmd...)
|
||||
}
|
||||
|
||||
func (d *DataLayer) forceNamespace(s *string) {
|
||||
if d.Namespace != "" {
|
||||
*s = d.Namespace
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataLayer) CheckConnectivity() error {
|
||||
contexts, err := d.ListContexts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(contexts) < 1 {
|
||||
log.Debugf("Did not find any contexts, will try checking k8s")
|
||||
_, err := d.runCommandKubectl("get", "pods")
|
||||
if err != nil {
|
||||
log.Debugf("The error were: %s", err)
|
||||
return errors.New("did not find any kubectl contexts configured")
|
||||
}
|
||||
log.Infof("Assuming k8s environment")
|
||||
d.StatusInfo.ClusterMode = true
|
||||
}
|
||||
|
||||
_, err = d.runCommandHelm("--help")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DataLayer) GetStatus() *StatusInfo {
|
||||
sum := float64(d.Cache.HitCount + d.Cache.MissCount)
|
||||
if sum > 0 {
|
||||
d.StatusInfo.CacheHitRatio = float64(d.Cache.HitCount) / sum
|
||||
} else {
|
||||
d.StatusInfo.CacheHitRatio = 0
|
||||
}
|
||||
return d.StatusInfo
|
||||
}
|
||||
|
||||
func (d *DataLayer) ListInstalled() (res []ReleaseElement, err error) {
|
||||
cmd := []string{"ls", "--all", "--output", "json", "--time-format", time.RFC3339}
|
||||
|
||||
// TODO: filter by namespace
|
||||
if d.Namespace == "" {
|
||||
cmd = append(cmd, "--all-namespaces")
|
||||
} else {
|
||||
cmd = append(cmd, "--namespace", d.Namespace)
|
||||
}
|
||||
|
||||
out, err := d.Cache.String(CacheKeyRelList, nil, func() (string, error) {
|
||||
return d.runCommandHelm(cmd...)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(out), &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (d *DataLayer) ReleaseHistory(namespace string, releaseName string) (res []*HistoryElement, err error) {
|
||||
// TODO: there is `max` but there is no `offset`
|
||||
ct := cacheTagRelease(namespace, releaseName)
|
||||
out, err := d.Cache.String(CacheKeyRelHistory+ct, []string{ct}, func() (string, error) {
|
||||
return d.runCommandHelm("history", releaseName, "--namespace", namespace, "--output", "json")
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(out), &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, elm := range res {
|
||||
chartRepoName, curVer, err := utils.ChartAndVersion(elm.Chart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
elm.ChartName = chartRepoName // TODO: move it to frontend?
|
||||
elm.ChartVer = curVer
|
||||
elm.Updated.Time = elm.Updated.Time.Round(time.Second)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type SectionFn = func(string, string, int, bool) (string, error) // TODO: rework it into struct-based argument?
|
||||
|
||||
func (d *DataLayer) RevisionManifests(namespace string, chartName string, revision int, _ bool) (res string, err error) {
|
||||
cmd := []string{"get", "manifest", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision)}
|
||||
|
||||
key := CacheKeyRevManifests + "\v" + namespace + "\v" + chartName + "\v" + strconv.Itoa(revision)
|
||||
return d.Cache.String(key, nil, func() (string, error) {
|
||||
return d.runCommandHelm(cmd...)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DataLayer) RevisionManifestsParsed(namespace string, chartName string, revision int) ([]*v1.Carp, error) {
|
||||
out, err := d.RevisionManifests(namespace, chartName, revision, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dec := yaml.NewDecoder(bytes.NewReader([]byte(out)))
|
||||
|
||||
res := make([]*v1.Carp, 0)
|
||||
var tmp interface{}
|
||||
for dec.Decode(&tmp) == nil {
|
||||
// k8s libs uses only JSON tags defined, say hello to https://github.com/go-yaml/yaml/issues/424
|
||||
// we can juggle it
|
||||
jsoned, err := json.Marshal(tmp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var doc v1.Carp
|
||||
err = json.Unmarshal(jsoned, &doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if doc.Kind == "" {
|
||||
log.Warnf("Manifest piece is not k8s resource: %s", jsoned)
|
||||
continue
|
||||
}
|
||||
|
||||
res = append(res, &doc)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (d *DataLayer) RevisionNotes(namespace string, chartName string, revision int, _ bool) (res string, err error) {
|
||||
cmd := []string{"get", "notes", chartName, "--namespace", namespace, "--revision", strconv.Itoa(revision)}
|
||||
key := CacheKeyRevNotes + "\v" + namespace + "\v" + chartName + "\v" + strconv.Itoa(revision)
|
||||
return d.Cache.String(key, nil, func() (string, error) {
|
||||
return d.runCommandHelm(cmd...)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DataLayer) RevisionValues(namespace string, chartName string, revision int, onlyUserDefined bool) (res string, err error) {
|
||||
cmd := []string{"get", "values", chartName, "--namespace", namespace, "--output", "yaml", "--revision", strconv.Itoa(revision)}
|
||||
|
||||
if !onlyUserDefined {
|
||||
cmd = append(cmd, "--all")
|
||||
}
|
||||
|
||||
key := CacheKeyRevValues + "\v" + namespace + "\v" + chartName + "\v" + strconv.Itoa(revision) + "\v" + fmt.Sprintf("%v", onlyUserDefined)
|
||||
return d.Cache.String(key, nil, func() (string, error) {
|
||||
return d.runCommandHelm(cmd...)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DataLayer) ReleaseUninstall(namespace string, name string) error {
|
||||
d.Cache.Invalidate(CacheKeyRelList, cacheTagRelease(namespace, name))
|
||||
|
||||
_, err := d.runCommandHelm("uninstall", name, "--namespace", namespace)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DataLayer) Rollback(namespace string, name string, rev int) error {
|
||||
d.Cache.Invalidate(CacheKeyRelList, cacheTagRelease(namespace, name))
|
||||
|
||||
_, err := d.runCommandHelm("rollback", name, strconv.Itoa(rev), "--namespace", namespace)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DataLayer) ChartInstall(namespace string, name string, repoChart string, version string, justTemplate bool, values string, reuseVals bool) (string, error) {
|
||||
if values == "" && reuseVals {
|
||||
oldVals, err := d.RevisionValues(namespace, name, 0, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values = oldVals
|
||||
}
|
||||
|
||||
valsFile, close1, err := utils.TempFile(values)
|
||||
defer close1()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := []string{"upgrade", "--install", "--create-namespace", name, repoChart, "--version", version, "--namespace", namespace, "--values", valsFile, "--output", "json"}
|
||||
if justTemplate {
|
||||
cmd = append(cmd, "--dry-run")
|
||||
}
|
||||
|
||||
out, err := d.runCommandHelm(cmd...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !justTemplate {
|
||||
d.Cache.Invalidate(CacheKeyRelList, cacheTagRelease(namespace, name))
|
||||
}
|
||||
|
||||
res := release.Release{}
|
||||
err = json.Unmarshal([]byte(out), &res)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if justTemplate {
|
||||
out = strings.TrimSpace(res.Manifest)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *DataLayer) RunTests(namespace string, name string) (string, error) {
|
||||
cmd := []string{"test", name, "--namespace", namespace, "--logs"}
|
||||
|
||||
out, err := d.runCommandHelm(cmd...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func RevisionDiff(functor SectionFn, ext string, namespace string, name string, revision1 int, revision2 int, flag bool) (string, error) {
|
||||
if revision1 == 0 || revision2 == 0 {
|
||||
log.Debugf("One of revisions is zero: %d %d", revision1, revision2)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
manifest1, err := functor(namespace, name, revision1, flag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
manifest2, err := functor(namespace, name, revision2, flag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
diff := GetDiff(manifest1, manifest2, strconv.Itoa(revision1)+ext, strconv.Itoa(revision2)+ext)
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
func GetDiff(text1 string, text2 string, name1 string, name2 string) string {
|
||||
edits := myers.ComputeEdits(span.URIFromPath(""), text1, text2)
|
||||
unified := gotextdiff.ToUnified(name1, name2, text1, edits)
|
||||
diff := fmt.Sprint(unified)
|
||||
log.Debugf("The diff is: %s", diff)
|
||||
return diff
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,34 +10,24 @@ import (
|
||||
func TestGetQueryProps(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint string
|
||||
revRequired bool
|
||||
wantErr bool
|
||||
name string
|
||||
endpoint string
|
||||
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 - all set with revRequired true",
|
||||
wantErr: false,
|
||||
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,
|
||||
endpoint: "/api/v1/namespaces/komodorio/charts?name=testing&revision=1",
|
||||
},
|
||||
{
|
||||
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",
|
||||
name: "Get query props - no name with revRequired true",
|
||||
wantErr: 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
|
||||
|
||||
Reference in New Issue
Block a user